feat: implement hierarchical configuration (system, tenant, bank) (#329)

* feat: implement hierarchical configuration (system, tenant, bank)

* feat: implement hierarchical configuration (system, tenant, bank)

* docs: add instructions for hierarchical config in CLAUDE.md

* feat: add ENABLE_BANK_CONFIG_API flag (disabled by default)

- Add HINDSIGHT_API_ENABLE_BANK_CONFIG_API env var (default: false)
- Return 403 Forbidden from bank config endpoints when disabled
- Update tests to enable the flag
- Update CLAUDE.md documentation

This provides security control over the bank configuration API,
ensuring it's only accessible when explicitly enabled.

* docs: add hierarchical configuration section

* feat(cli): add bank config commands (config, set-config, reset-config)

- Add 'hindsight bank config' to view bank configuration
- Add 'hindsight bank set-config' to update LLM settings per bank
- Add 'hindsight bank reset-config' to reset to defaults
- Implements client API calls to new bank config endpoints

* fix(cli): fix compilation errors in bank config commands

- Fix type signature: use ApiClient instead of api::Client
- Fix confirmation: use ui::prompt_confirmation instead of ui::confirm
- Fix error handling: use anyhow! macro instead of errors::Error
- Fix type conversion: convert HashMap to serde_json::Map for API call

* feat: implement type-safe hierarchical config with bank overrides

Implements a production-ready hierarchical configuration system that prevents
accidentally using global defaults when bank-specific overrides exist.

- Created StaticConfigProxy that wraps HindsightConfig
- get_config() now returns proxy that blocks access to bank-configurable fields
- Raises ConfigFieldAccessError with clear message when accessing configurable fields
- Added _get_raw_config() for internal use only
- Forces developers to use resolve_full_config(bank_id, context) for bank settings

- Added resolve_full_config() method that returns complete HindsightConfig
- Resolves hierarchy: Global (env) → Tenant → Bank
- No caching to support multi-server deployments (always fresh from DB)
- LLM provider pooling handles expensive operations separately

- Updated entire retain pipeline to pass resolved config through call chain
- memory_engine.py: Resolves config at top level where bank_id/context available
- orchestrator.py: Accepts and passes config to fact_extraction
- fact_extraction.py: Uses passed config instead of get_config()
- utils.py: Added optional config param for backward compatibility

- consolidator.py: Uses resolve_full_config() for enable_observations check
- memory_engine.py: Resolves config before triggering consolidation

- Renamed "Memory Bank" to "Bank Configuration" with tabs
- Combined Stats and Operations into "General" tab
- Consolidated Profile and Configuration into "Configuration" tab
- Moved Actions dropdown to page level (outside tabs)

- Created new component for managing bank-specific config
- Displays configurable fields: retain_chunk_size, retain_extraction_mode, etc.
- Edit via dialog with form validation
- Reset to defaults via AlertDialog confirmation
- Shows field IDs in monospace for clarity
- Visual separation with borders and hover effects

- Removed inline edit mode, switched to dialog-based editing
- Separate dialogs for Disposition and Mission editing
- Read-only display with clear edit buttons
- Removed duplicate stats cards and operations

- bank-stats-view.tsx: Overview statistics (memories, links, documents, pending ops)
- bank-operations-view.tsx: Background operations table with filtering

**Problem**: Consolidation always used global enable_observations, ignoring bank overrides
**Root Cause**: consolidator.py called get_config() instead of resolving bank-specific config
**Solution**: Pass resolved config through the entire pipeline

**Problem**: asyncpg returning JSONB as JSON string instead of parsed dict
**Solution**: Explicit JSON parsing in config_resolver.py with type checking

- All 19 API integration tests pass
- All 10 hierarchical config tests pass
- Retain operations work correctly with bank-specific config
- Consolidation respects bank-specific enable_observations setting

- Updated developer/configuration.md with type-safe config access pattern
- Added examples showing correct usage patterns
- Documented ConfigFieldAccessError and resolution methods

- get_config() now returns StaticConfigProxy (blocks configurable field access)
- Code accessing bank-configurable fields must use resolve_full_config()
- Clear migration path with helpful error messages

Fixes hierarchical configuration to be production-ready with proper type safety.

* refactor: remove LLM client pool and simplify config resolver

Since LLM config (provider, model, api_key) is now static and not
bank-configurable, the LLMClientPool is no longer needed.

Changes:
- Remove hindsight_api/llm_client_pool.py (no longer needed)
- Remove memory_engine._get_bank_llm_config() (dead code, never called)
- Simplify config_resolver.py by eliminating duplication between
  resolve_full_config() and get_bank_config()
- get_bank_config() now calls resolve_full_config() and filters results
- Remove outdated "LLM provider pooling" comments from docstrings

All tests pass (10 hierarchical config tests, 19 API integration tests)

* fix: update tests to use _get_raw_config() for configurable fields

Fixed test fixtures that were accessing configurable fields (like
enable_observations) from get_config(), which now raises
ConfigFieldAccessError due to type-safe config access.

Changes:
- test_consolidation.py: Changed enable_observations fixture to use
  _get_raw_config() instead of get_config()
- test_consolidation.py: Updated test_consolidation_returns_disabled_status
  to set bank config instead of mocking get_config()
- test_link_expansion_retrieval.py: Changed fixture to use _get_raw_config()
- test_observations.py: Changed disable_observations fixture to use
  _get_raw_config()
- Regenerated OpenAPI spec and clients

All 39 previously failing tests now pass.

* fix: add missing config parameter to test calls of extract_facts_from_text()

Fixed 45 test failures where tests were calling extract_facts_from_text()
without the new required config parameter.

Changes:
- Added config=_get_raw_config() to all extract_facts_from_text() calls
- Fixed test_main_module.py to patch _get_raw_config instead of get_config
- Updated 6 test files with 37 function call sites

All tests should now pass.

* fix: add missing config parameter to test_skip_podcast_meta_commentary

One more test was missing the config parameter for extract_facts_from_text().
This commit is contained in:
Nicolò Boschi 2026-02-12 13:14:57 +01:00 committed by GitHub
parent f9a8a8e01e
commit 8d731f2e5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 5026 additions and 609 deletions

View file

@ -238,26 +238,61 @@ def process(data: UserData) -> str:
### Adding New API Configuration Flags
When adding a new environment variable configuration:
Configuration follows a hierarchical system: **Global (env vars) → Tenant (via extension) → Bank (database)**.
Fields must be categorized as either **hierarchical** (can be overridden per-tenant/bank) or **static** (server-level only).
#### Adding a New Configuration Field
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
- Add `ENV_*` constant for the environment variable name
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass
- Add field to `HindsightConfig` dataclass with type annotation
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
- Add initialization in `from_env()` method
```python
# Hierarchical field (can be overridden per-bank)
_HIERARCHICAL_FIELDS = {
...,
"my_setting", # Add here for hierarchical
}
# Static field - just don't add to _HIERARCHICAL_FIELDS
```
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use the config** in code:
3. **Use hierarchical config in MemoryEngine**:
```python
# Config is resolved automatically per bank via ConfigResolver
config_dict = await self._config_resolver.get_bank_config(bank_id, context)
value = config_dict["my_setting"]
```
4. **Use static config** (non-hierarchical):
```python
from ...config import get_config
config = get_config()
value = config.your_new_field
value = config.my_static_field
```
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
5. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
- LLM settings (provider, model, API key, base URL)
- Operation-specific settings (retain mode, chunk size, etc.)
- Feature flags that vary by customer/bank
**Static** (server-level only):
- Infrastructure settings (database URL, port, host)
- Global limits (max concurrent operations)
- System-wide feature flags
## Environment Setup
@ -281,3 +316,4 @@ Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_RERANKER_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_DATABASE_URL`: External PostgreSQL (uses embedded pg0 by default)
- `HINDSIGHT_API_ENABLE_BANK_CONFIG_API`: Enable per-bank config API (default: false, disabled for security)

View file

@ -0,0 +1,64 @@
"""Add config JSONB column to banks table for hierarchical configuration
Revision ID: x9s0t1u2v3w4
Revises: w8r9s0t1u2v3
Create Date: 2026-02-09
This migration adds a `config` JSONB column to the banks table to support
per-bank configuration overrides. This enables hierarchical configuration where:
- Global config is loaded from environment variables
- Tenant config is provided via TenantExtension
- Bank config overrides are stored in banks.config JSONB column
The config column stores overrides for hierarchical fields (LLM settings,
retention parameters, retrieval settings, etc.) in Python field name format.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "x9s0t1u2v3w4"
down_revision: str | Sequence[str] | None = "w8r9s0t1u2v3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add config JSONB column to banks table with GIN index."""
schema = _get_schema_prefix()
# Add config column to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN config JSONB NOT NULL DEFAULT '{{}}'::jsonb
""")
# Add GIN index for efficient JSONB queries
op.execute(f"""
CREATE INDEX idx_banks_config
ON {schema}banks
USING gin(config)
""")
def downgrade() -> None:
"""Remove config column and index from banks table."""
schema = _get_schema_prefix()
# Drop index first
op.execute(f"DROP INDEX IF EXISTS {schema}idx_banks_config")
# Drop column
op.execute(f"""
ALTER TABLE {schema}banks
DROP COLUMN IF EXISTS config
""")

View file

@ -70,6 +70,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)
from hindsight_api.config import get_config
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
@ -826,6 +827,55 @@ class CreateBankRequest(BaseModel):
background: str | None = Field(default=None, description="Deprecated: use mission instead")
class BankConfigUpdate(BaseModel):
"""Request model for updating bank configuration."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"updates": {
"llm_model": "claude-sonnet-4-5",
"retain_extraction_mode": "verbose",
"retain_custom_instructions": "Extract technical details carefully",
}
}
}
)
updates: dict[str, Any] = Field(
description="Configuration overrides. Keys can be in Python field format (llm_provider) "
"or environment variable format (HINDSIGHT_API_LLM_PROVIDER). "
"Only hierarchical fields can be overridden per-bank."
)
class BankConfigResponse(BaseModel):
"""Response model for bank configuration."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"bank_id": "my-bank",
"config": {
"llm_provider": "openai",
"llm_model": "gpt-4",
"retain_extraction_mode": "verbose",
},
"overrides": {
"llm_model": "gpt-4",
"retain_extraction_mode": "verbose",
},
}
}
)
bank_id: str = Field(description="Bank identifier")
config: dict[str, Any] = Field(
description="Fully resolved configuration with all hierarchical overrides applied (Python field names)"
)
overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
class GraphDataResponse(BaseModel):
"""Response model for graph data endpoint."""
@ -1355,6 +1405,7 @@ class FeaturesInfo(BaseModel):
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
class VersionResponse(BaseModel):
@ -1368,6 +1419,7 @@ class VersionResponse(BaseModel):
"observations": False,
"mcp": True,
"worker": True,
"bank_config_api": False,
},
}
}
@ -1647,17 +1699,21 @@ def _register_routes(app: FastAPI):
Returns version info and feature flags that can be used by clients
to determine which capabilities are available.
Note: observations flag shows the global default. Individual banks
may override this setting via bank-specific configuration.
"""
from hindsight_api import __version__
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config
config = get_config()
config = _get_raw_config()
return VersionResponse(
api_version=__version__,
features=FeaturesInfo(
observations=config.enable_observations,
mcp=config.mcp_enabled,
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
),
)
@ -3311,6 +3367,112 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Get bank configuration",
description="Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). "
"The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.",
operation_id="get_bank_config",
tags=["Banks"],
)
async def api_get_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Get configuration for a bank with all hierarchical overrides applied."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Get resolved config from config resolver
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
# Get bank-specific overrides only
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Update bank configuration",
description="Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). "
"Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).",
operation_id="update_bank_config",
tags=["Banks"],
)
async def api_update_bank_config(
bank_id: str, request: BankConfigUpdate, request_context: RequestContext = Depends(get_request_context)
):
"""Update configuration overrides for a bank."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Update config via config resolver (validates configurable fields and permissions)
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
# Return updated config
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
except ValueError as e:
# Validation error (e.g., trying to override static field)
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Reset bank configuration",
description="Reset bank configuration to defaults by removing all bank-specific overrides. "
"The bank will then use global and tenant-level configuration only.",
operation_id="reset_bank_config",
tags=["Banks"],
)
async def api_reset_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Reset bank configuration to defaults (remove all overrides)."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Reset config via config resolver
await app.state.memory._config_resolver.reset_bank_config(bank_id)
# Return updated config (should match defaults now)
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidate",
response_model=ConsolidationResponse,

View file

@ -8,8 +8,9 @@ import json
import logging
import os
import sys
from dataclasses import dataclass
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from typing import Any
from dotenv import find_dotenv, load_dotenv
@ -18,6 +19,103 @@ load_dotenv(find_dotenv(usecwd=True), override=True)
logger = logging.getLogger(__name__)
class ConfigFieldAccessError(AttributeError):
"""Raised when trying to access a bank-configurable field from global config."""
pass
class StaticConfigProxy:
"""
Proxy that wraps HindsightConfig and only allows access to static (non-configurable) fields.
Raises ConfigFieldAccessError when trying to access configurable fields that vary per-bank.
Forces developers to use get_resolved_config(bank_id, context) for bank-specific settings.
"""
def __init__(self, config: "HindsightConfig"):
object.__setattr__(self, "_config", config)
object.__setattr__(self, "_configurable_fields", HindsightConfig.get_configurable_fields())
def __getattribute__(self, name: str):
if name.startswith("_"):
return object.__getattribute__(self, name)
configurable_fields = object.__getattribute__(self, "_configurable_fields")
if name in configurable_fields:
raise ConfigFieldAccessError(
f"Field '{name}' is bank-configurable and cannot be accessed from global config. "
f"Use ConfigResolver.resolve_full_config(bank_id, context) to get bank-specific config. "
f"This prevents accidentally using global defaults when bank-specific overrides exist."
)
config = object.__getattribute__(self, "_config")
return getattr(config, name)
def __setattr__(self, name: str, value):
raise AttributeError("Config is read-only. Modifications must go through ConfigResolver.")
# Configuration field markers for hierarchical configuration
def hierarchical(default_value):
"""
Mark a config field as hierarchical (can be overridden per-tenant/bank).
Hierarchical fields can be customized at the tenant or bank level via database
configuration. Examples: LLM settings, retention parameters, retrieval settings.
"""
return field(default=default_value, metadata={"hierarchical": True})
def static(default_value):
"""
Mark a config field as static (server-level only, cannot be overridden).
Static fields are infrastructure-level settings that affect the entire server
and cannot vary per tenant or bank. Examples: database URL, API port, worker settings.
"""
return field(default=default_value, metadata={"hierarchical": False})
# Configuration key normalization utilities
def normalize_config_key(key: str) -> str:
"""
Convert environment variable format to Python field name format.
Examples:
HINDSIGHT_API_LLM_PROVIDER -> llm_provider
LLM_MODEL -> llm_model
llm_model -> llm_model (already normalized)
Args:
key: Environment variable name or Python field name
Returns:
Normalized Python field name (lowercase snake_case)
"""
if key.startswith("HINDSIGHT_API_"):
key = key[len("HINDSIGHT_API_") :]
return key.lower()
def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
"""
Normalize all keys in a config dict to Python field names.
Allows users to provide config overrides in either format:
- Python field format: {"llm_provider": "openai"}
- Env var format: {"HINDSIGHT_API_LLM_PROVIDER": "openai"}
Args:
config: Dict with env var or Python field names as keys
Returns:
Dict with all keys normalized to Python field names
"""
return {normalize_config_key(k): v for k, v in config.items()}
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
@ -114,6 +212,7 @@ ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
@ -236,6 +335,7 @@ DEFAULT_LOG_LEVEL = "info"
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
DEFAULT_WORKERS = 1
DEFAULT_MCP_ENABLED = True
DEFAULT_ENABLE_BANK_CONFIG_API = False # Disabled by default for security
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
@ -446,6 +546,7 @@ class HindsightConfig:
log_level: str
log_format: str
mcp_enabled: bool
enable_bank_config_api: bool
# Recall
graph_retriever: str
@ -498,6 +599,92 @@ class HindsightConfig:
otel_service_name: str
otel_deployment_environment: str
# Class-level sets for configuration categorization
# CREDENTIAL_FIELDS: Never exposed via API, never configurable per-tenant/bank
_CREDENTIAL_FIELDS = {
# API Keys
"llm_api_key",
"retain_llm_api_key",
"reflect_llm_api_key",
"consolidation_llm_api_key",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
"reflect_llm_base_url",
"consolidation_llm_base_url",
"embeddings_tei_base_url",
"reranker_tei_base_url",
"reranker_cohere_base_url",
# Service Account Keys
"llm_vertexai_service_account_key",
}
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
# These fields are manually tagged as safe to expose and modify.
# Excludes credentials, infrastructure config, provider/model selection, and performance tuning.
_CONFIGURABLE_FIELDS = {
# Retention settings (behavioral)
"retain_chunk_size",
"retain_extraction_mode",
"retain_custom_instructions",
# Consolidation settings
"enable_observations",
}
@classmethod
def get_configurable_fields(cls) -> set[str]:
"""
Get set of field names that are configurable per-tenant/bank via API.
Configurable fields are manually tagged behavioral settings that are safe
to expose and modify (e.g., retain_chunk_size, custom_instructions).
Excludes credentials, infrastructure config, and provider/model selection.
Returns:
Set of configurable field names
"""
return cls._CONFIGURABLE_FIELDS.copy()
@classmethod
def get_credential_fields(cls) -> set[str]:
"""
Get set of field names that are credentials (NEVER exposed via API).
Credential fields include API keys, base URLs, and service account keys.
These must never be returned in API responses or accepted in updates.
Returns:
Set of credential field names
"""
return cls._CREDENTIAL_FIELDS.copy()
@classmethod
def get_hierarchical_fields(cls) -> set[str]:
"""
DEPRECATED: Use get_configurable_fields() instead.
Kept for backward compatibility during migration.
"""
return cls.get_configurable_fields()
@classmethod
def get_static_fields(cls) -> set[str]:
"""
Get set of field names that are static (server-level only).
Static fields are infrastructure-level settings that cannot vary
per tenant or bank. These include database config, API port, worker settings, etc.
Also includes credential fields which are never configurable.
Returns:
Set of static field names
"""
# Get all field names from dataclass
all_fields = {f.name for f in fields(cls)}
# Static fields = all fields - configurable fields
return all_fields - cls._CONFIGURABLE_FIELDS
def validate(self) -> None:
"""Validate configuration values and raise errors for invalid combinations."""
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
@ -669,6 +856,8 @@ class HindsightConfig:
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
@ -809,8 +998,35 @@ class HindsightConfig:
_config_cache: HindsightConfig | None = None
def get_config() -> HindsightConfig:
"""Get the cached configuration, loading from environment on first call."""
def get_config() -> StaticConfigProxy:
"""
Get global configuration with ONLY static (non-configurable) fields accessible.
This returns a proxy that prevents access to bank-configurable fields
(like enable_observations, retain_chunk_size, etc.).
For bank-specific configuration, use:
config_resolver.resolve_full_config(bank_id, context)
This design prevents accidentally using global defaults when bank-specific
overrides exist.
Returns:
StaticConfigProxy that only exposes static infrastructure fields
Raises:
ConfigFieldAccessError: If you try to access a bank-configurable field
"""
return StaticConfigProxy(_get_raw_config())
def _get_raw_config() -> HindsightConfig:
"""
Get raw config (internal use only).
INTERNAL USE ONLY. Do not use this directly in application code.
Use get_config() for static fields or ConfigResolver.resolve_full_config() for bank-specific config.
"""
global _config_cache
if _config_cache is None:
_config_cache = HindsightConfig.from_env()

View file

@ -0,0 +1,274 @@
"""
Configuration resolution with hierarchical overrides.
Resolves config values through the hierarchy:
Global (env vars) Tenant config (via extension) Bank config (database)
Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import json
import logging
from dataclasses import asdict
from typing import Any
import asyncpg
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
def __init__(self, pool: asyncpg.Pool, tenant_extension: TenantExtension | None = None):
"""
Initialize config resolver.
Args:
pool: Database connection pool
tenant_extension: Optional tenant extension for tenant-level config and permissions
"""
self.pool = pool
self.tenant_extension = tenant_extension
self._global_config = _get_raw_config()
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
This is for INTERNAL USE ONLY. Returns the complete config object with all fields
including credentials and static fields. Use get_bank_config() for API responses.
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
if bank_overrides:
config_dict.update(bank_overrides)
logger.debug(f"Applied bank config overrides for bank {bank_id}: {list(bank_overrides.keys())}")
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""
Get fully resolved config for a bank (filtered by permissions).
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Note: Config is resolved on every call (not cached) to ensure consistency
across multiple API servers.
SECURITY:
- Only returns configurable fields (excludes static/infrastructure fields)
- Filters out ALL credential fields (API keys, base URLs, etc.)
- Further filtered by tenant/bank permissions if extension provides them
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution and permissions
Returns:
Dict of allowed configurable fields only (never includes credentials or static fields)
"""
# Resolve full config with all hierarchical overrides
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
# PERMISSIONS: Further filter based on tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
Load bank config overrides from banks.config JSONB column.
Args:
bank_id: Bank identifier
Returns:
Dict of config overrides (only configurable fields, normalized keys)
"""
try:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT config FROM banks WHERE bank_id = $1
""",
bank_id,
)
if row and row["config"]:
config_data = row["config"]
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only return overrides for configurable fields
return {k: v for k, v in normalized.items() if k in self._configurable_fields}
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
return {}
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
"""
Update bank configuration overrides (with permission checking).
Args:
bank_id: Bank identifier
updates: Dict of config field names to new values.
Keys can be in env var format (HINDSIGHT_API_LLM_PROVIDER)
or Python field format (llm_provider).
Only configurable fields are allowed.
context: Request context for permission checking
Raises:
ValueError: If attempting to override invalid/disallowed fields
"""
# Normalize keys
normalized_updates = normalize_config_dict(updates)
# SECURITY: Reject credential fields explicitly
credential_attempts = set(normalized_updates.keys()) & self._credential_fields
if credential_attempts:
raise ValueError(
f"Cannot set credential fields via API: {sorted(credential_attempts)}. "
f"Credentials (API keys, base URLs) must be set at server level only."
)
# Validate all fields are configurable
invalid_fields = set(normalized_updates.keys()) - self._configurable_fields
if invalid_fields:
static_fields = HindsightConfig.get_static_fields()
invalid_static = invalid_fields & static_fields
if invalid_static:
raise ValueError(
f"Cannot override static (server-level) fields: {sorted(invalid_static)}. "
f"Only configurable fields can be overridden per-bank. "
f"Configurable fields include: {sorted(list(self._configurable_fields)[:10])}... "
f"(total: {len(self._configurable_fields)} fields)"
)
else:
raise ValueError(
f"Unknown configuration fields: {sorted(invalid_fields)}. "
f"Valid configurable fields: {sorted(list(self._configurable_fields)[:10])}..."
)
# PERMISSIONS: Check tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
disallowed = set(normalized_updates.keys()) - allowed_fields
if disallowed:
raise ValueError(
f"Not allowed to modify fields: {sorted(disallowed)}. "
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
if allowed_fields
else "Not allowed to modify fields: {sorted(disallowed)}. "
"Your permissions do not allow any config modifications."
)
except ValueError:
raise # Re-raise permission errors
except Exception as e:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET config = config || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
""",
json.dumps(normalized_updates),
bank_id,
)
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
async def reset_bank_config(self, bank_id: str) -> None:
"""
Reset bank configuration to defaults (remove all overrides).
Args:
bank_id: Bank identifier
"""
async with self.pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET config = '{}'::jsonb,
updated_at = now()
WHERE bank_id = $1
""",
bank_id,
)
logger.info(f"Reset bank config for {bank_id} to defaults")

View file

@ -82,9 +82,8 @@ async def run_consolidation_job(
Returns:
Dict with consolidation results
"""
from ...config import get_config
config = get_config()
# Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size

View file

@ -1018,6 +1018,12 @@ class MemoryEngine(MemoryEngineInterface):
# Initialize entity resolver with pool
self.entity_resolver = EntityResolver(self._pool)
# Initialize config resolver for hierarchical configuration
from ..config_resolver import ConfigResolver
self._config_resolver = ConfigResolver(pool=self._pool, tenant_extension=self._tenant_extension)
logger.debug("Config resolver initialized for hierarchical configuration")
# Set executor for task backend and initialize
self._task_backend.set_executor(self.execute_task)
await self._task_backend.initialize()
@ -1447,6 +1453,7 @@ class MemoryEngine(MemoryEngineInterface):
sub_results, sub_usage = await self._retain_batch_async_internal(
bank_id=bank_id,
contents=sub_batch,
request_context=request_context,
document_id=document_id,
is_first_batch=i == 1, # Only upsert on first batch
fact_type_override=fact_type_override,
@ -1466,6 +1473,7 @@ class MemoryEngine(MemoryEngineInterface):
result, total_usage = await self._retain_batch_async_internal(
bank_id=bank_id,
contents=contents,
request_context=request_context,
document_id=document_id,
is_first_batch=True,
fact_type_override=fact_type_override,
@ -1497,9 +1505,8 @@ class MemoryEngine(MemoryEngineInterface):
logger.warning(f"Post-retain hook error (non-fatal): {e}")
# Trigger consolidation as a tracked async operation if enabled
from ..config import get_config
config = get_config()
# Resolve bank-specific config to check if observations are enabled for this bank
config = await self._config_resolver.resolve_full_config(bank_id, request_context)
if config.enable_observations:
try:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
@ -1515,6 +1522,7 @@ class MemoryEngine(MemoryEngineInterface):
self,
bank_id: str,
contents: list[RetainContentDict],
request_context: "RequestContext",
document_id: str | None = None,
is_first_batch: bool = True,
fact_type_override: str | None = None,
@ -1532,6 +1540,7 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: Unique identifier for the bank
contents: List of dicts with content, context, event_date
request_context: Request context for config resolution
document_id: Optional document ID (always upserts if exists)
is_first_batch: Whether this is the first batch (for chunked operations, only delete on first batch)
fact_type_override: Override fact type for all facts
@ -1548,6 +1557,9 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool()
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@ -1564,6 +1576,7 @@ class MemoryEngine(MemoryEngineInterface):
fact_type_override=fact_type_override,
confidence_score=confidence_score,
document_tags=document_tags,
config=resolved_config,
)
def recall(

View file

@ -702,6 +702,7 @@ async def _extract_facts_from_chunk(
event_date: datetime,
context: str,
llm_config: "LLMConfig",
config,
agent_name: str = None,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
@ -721,7 +722,6 @@ async def _extract_facts_from_chunk(
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
# Check config for extraction mode and causal link extraction
config = get_config()
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
@ -1055,6 +1055,7 @@ async def _extract_facts_with_auto_split(
event_date: datetime,
context: str,
llm_config: LLMConfig,
config,
agent_name: str = None,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
@ -1070,6 +1071,7 @@ async def _extract_facts_with_auto_split(
event_date: Reference date for temporal information
context: Context about the conversation/document
llm_config: LLM configuration to use
config: Resolved HindsightConfig for this bank
agent_name: Optional agent name (memory owner)
Returns:
@ -1088,6 +1090,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
)
except OutputTooLongError:
@ -1132,6 +1135,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
),
_extract_facts_with_auto_split(
@ -1141,6 +1145,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
),
]
@ -1164,6 +1169,7 @@ async def extract_facts_from_text(
event_date: datetime,
llm_config: LLMConfig,
agent_name: str,
config,
context: str = "",
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
"""
@ -1178,9 +1184,10 @@ async def extract_facts_from_text(
Args:
text: Input text (conversation, article, etc.)
event_date: Reference date for resolving relative times
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Agent name (memory owner)
config: Resolved HindsightConfig for this bank
context: Context about the conversation/document
Returns:
Tuple of (facts, chunks, usage) where:
@ -1188,7 +1195,6 @@ async def extract_facts_from_text(
- chunks: List of tuples (chunk_text, fact_count) for each chunk
- usage: Aggregated token usage across all LLM calls
"""
config = get_config()
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
# Log chunk count before starting LLM requests
@ -1207,6 +1213,7 @@ async def extract_facts_from_text(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
)
for i, chunk in enumerate(chunks)
@ -1239,7 +1246,7 @@ SECONDS_PER_FACT = 10
async def extract_facts_from_contents(
contents: list[RetainContent], llm_config, agent_name: str
contents: list[RetainContent], llm_config, agent_name: str, config
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
Extract facts from multiple content items in parallel.
@ -1254,6 +1261,7 @@ async def extract_facts_from_contents(
contents: List of RetainContent objects to process
llm_config: LLM configuration for fact extraction
agent_name: Name of the agent (for agent-related fact detection)
config: Resolved HindsightConfig for this bank
Returns:
Tuple of (extracted_facts, chunks_metadata, usage)
@ -1272,6 +1280,7 @@ async def extract_facts_from_contents(
context=item.context,
llm_config=llm_config,
agent_name=agent_name,
config=config,
)
fact_extraction_tasks.append(task)

View file

@ -76,6 +76,7 @@ async def retain_batch(
duplicate_checker_fn,
bank_id: str,
contents_dicts: list[RetainContentDict],
config,
document_id: str | None = None,
is_first_batch: bool = True,
fact_type_override: str | None = None,
@ -94,6 +95,7 @@ async def retain_batch(
duplicate_checker_fn: Function to check for duplicate facts
bank_id: Bank identifier
contents_dicts: List of content dictionaries
config: Resolved HindsightConfig for this bank
document_id: Optional document ID
is_first_batch: Whether this is the first batch
fact_type_override: Override fact type for all facts
@ -144,7 +146,9 @@ async def retain_batch(
# Step 1: Extract facts from all contents
step_start = time.time()
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(contents, llm_config, agent_name)
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, config
)
log_buffer.append(
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
)

View file

@ -19,6 +19,7 @@ async def extract_facts(
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
config=None,
) -> tuple[list["Fact"], list[tuple[str, int]]]:
"""
Extract semantic facts from text using LLM.
@ -35,6 +36,7 @@ async def extract_facts(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name to help identify agent-related facts
config: HindsightConfig to use (defaults to global config if not provided)
Returns:
Tuple of (facts, chunks) where:
@ -47,12 +49,19 @@ async def extract_facts(
if not text or not text.strip():
return [], []
# Use provided config or fall back to global config
if config is None:
from ..config import _get_raw_config
config = _get_raw_config()
facts, chunks, _ = await extract_facts_from_text(
text,
event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name,
config=config,
context=context,
)
if not facts:

View file

@ -2,6 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from hindsight_api.extensions.base import Extension
from hindsight_api.models import RequestContext
@ -88,6 +89,54 @@ class TenantExtension(Extension, ABC):
"""
...
async def get_tenant_config(self, context: RequestContext) -> dict[str, Any]:
"""
Get tenant-specific configuration overrides.
This method is called during hierarchical configuration resolution to get
tenant-level config overrides. The returned dict should contain Python field
names (lowercase snake_case) as keys, not environment variable names.
Example:
{"llm_model": "gpt-4", "retain_extraction_mode": "verbose"}
The default implementation returns an empty dict (no tenant-specific config).
Override this method in custom extensions to provide tenant-specific configuration.
Args:
context: The request context containing tenant information.
Returns:
Dict of config field names to values (only configurable fields).
Empty dict if no tenant-specific config.
"""
return {}
async def get_allowed_config_fields(self, context: RequestContext, bank_id: str) -> set[str] | None:
"""
Get set of config fields that this tenant/bank is allowed to modify.
This method controls which configurable fields can be modified via the bank config API.
It enables fine-grained permission control per tenant or per bank.
Examples:
- Return None: Allow all configurable fields (default)
- Return {"retain_chunk_size", "retain_custom_instructions"}: Allow only these fields
- Return set(): Allow no modifications (read-only)
The default implementation returns None (all configurable fields allowed).
Override this method in custom extensions to implement custom permission logic.
Args:
context: The request context containing tenant information.
bank_id: The bank identifier for per-bank permissions.
Returns:
Set of allowed field names, or None to allow all configurable fields.
Returned fields must be a subset of HindsightConfig.get_configurable_fields().
"""
return None
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.

View file

@ -23,7 +23,7 @@ import uvicorn
from . import MemoryEngine, __version__
from .api import create_app
from .banner import print_banner
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, _get_raw_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
@ -68,7 +68,7 @@ def main():
global _memory
# Load configuration from environment (for CLI args defaults)
config = get_config()
config = _get_raw_config()
parser = argparse.ArgumentParser(
prog="hindsight-api",
@ -227,6 +227,7 @@ def main():
log_level=args.log_level,
log_format=config.log_format,
mcp_enabled=config.mcp_enabled,
enable_bank_config_api=config.enable_bank_config_api,
graph_retriever=config.graph_retriever,
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
recall_max_concurrent=config.recall_max_concurrent,

View file

@ -12,6 +12,7 @@ from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
@ -44,6 +45,7 @@ class TestCausalRelationsValidation:
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -88,6 +90,7 @@ class TestCausalRelationsValidation:
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -124,6 +127,7 @@ class TestCausalRelationsValidation:
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract facts about the causal chain"
@ -173,6 +177,7 @@ class TestCausalRelationsValidation:
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract facts"
@ -209,6 +214,7 @@ class TestCausalRelationsValidation:
context=context,
llm_config=llm_config,
agent_name="TestUser",
config=_get_raw_config(),
)
# Verify relation types are all backward-looking

View file

@ -10,6 +10,7 @@ from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
@ -37,7 +38,8 @@ After searching for weeks, I finally found a cheaper apartment in Brooklyn.
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser"
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
@ -106,7 +108,8 @@ The renovation took three months and cost $15,000.
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser"
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
@ -136,7 +139,8 @@ Machine learning fascinated me so much that I changed my career to data science.
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser"
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Check no fact references itself
@ -163,7 +167,8 @@ The new role enabled me to lead a team of engineers.
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser"
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
# Validate all indices (must reference PREVIOUS facts only)
@ -190,7 +195,8 @@ Reduced spending somewhat affected local businesses.
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser"
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser",
config=_get_raw_config(),
)
for i, fact in enumerate(facts):

View file

@ -21,9 +21,9 @@ from hindsight_api.engine.reflect.tools import (
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config
config = get_config()
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = True
yield
@ -563,25 +563,26 @@ class TestConsolidationDisabled:
self, memory: MemoryEngine, request_context
):
"""Test that consolidation returns disabled status when enable_observations is False."""
from unittest.mock import patch
bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Disable observations via config
with patch("hindsight_api.config.get_config") as mock_config:
mock_config.return_value.enable_observations = False
# Disable observations for this bank via bank config
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={"enable_observations": False},
context=request_context,
)
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "disabled"
assert result["bank_id"] == bank_id
assert result["status"] == "disabled"
assert result["bank_id"] == bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -8,7 +8,7 @@ from datetime import datetime
import pytest
from hindsight_api.config import get_config, clear_config_cache
from hindsight_api.config import get_config, clear_config_cache, _get_raw_config
from hindsight_api.engine.llm_wrapper import LLMConfig
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
@ -58,6 +58,7 @@ async def test_fact_extraction_basic_analysis(llm_config):
llm_config=llm_config,
agent_name="test-agent",
context="Friday Standup meeting",
config=_get_raw_config(),
)
duration = time.time() - start_time

View file

@ -11,6 +11,7 @@ from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
@ -44,7 +45,8 @@ I ran into my neighbor Sarah who mentioned she's planning a trip to Italy next m
event_date=datetime(2024, 6, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
input_length = len(text)
@ -88,7 +90,8 @@ User: Perfect, I'll make a reservation for Saturday at 7pm.
event_date=datetime(2024, 6, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
input_length = len(text)
@ -144,7 +147,8 @@ I edited about 20 photos from my recent trip to the mountains.
event_date=datetime(2024, 4, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
input_length = len(text)
@ -208,7 +212,8 @@ I edited about 20 photos from my recent trip to the mountains.
event_date=datetime(2023, 5, 8), # Date from locomo dataset
context=context,
llm_config=llm_config,
agent_name=data["conversation"]["speaker_a"]
agent_name=data["conversation"]["speaker_a"],
config=_get_raw_config(),
)
# Calculate ratios
@ -269,7 +274,8 @@ I'm planning to visit Japan next year.
event_date=datetime(2024, 6, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
# Count approximate number of statements (sentences)

View file

@ -17,6 +17,7 @@ from datetime import UTC, datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
# =============================================================================
@ -48,7 +49,8 @@ Marcus felt anxious about the upcoming interview.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -80,7 +82,8 @@ The music was so loud I could barely hear myself think.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -113,7 +116,8 @@ Maybe we should reconsider the timeline.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -146,7 +150,8 @@ I'm unable to attend the conference due to scheduling conflicts.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -178,7 +183,8 @@ Unlike last year, we're ahead of schedule.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -211,7 +217,8 @@ She's enthusiastic about the opportunity.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -244,7 +251,8 @@ I'm planning to switch careers because I'm not fulfilled in my current role.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -281,7 +289,8 @@ Family is the most important thing to her.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -315,7 +324,8 @@ I prefer presenting in person rather than virtually because I can read the room
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -372,7 +382,8 @@ I'm planning to visit Tokyo next month.
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -423,7 +434,8 @@ with a concert surrounded by music, joy and the warm summer breeze.
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Melanie"
agent_name="Melanie",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -493,7 +505,8 @@ It was a beautiful day and I plan to make this a regular habit.
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -547,7 +560,8 @@ It was a beautiful day and I plan to make this a regular habit.
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="Personal diary"
context="Personal diary",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -577,7 +591,8 @@ It was a beautiful day and I plan to make this a regular habit.
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="General info"
context="General info",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -604,7 +619,8 @@ It was a beautiful day and I plan to make this a regular habit.
event_date=reference_date,
llm_config=llm_config,
agent_name="TestUser",
context="Calendar events"
context="Calendar events",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -655,7 +671,8 @@ great time! Every time I see it, I can't help but smile.
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name="Deborah"
agent_name="Deborah",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -705,7 +722,8 @@ I've learned so much from it.
event_date=datetime(2024, 11, 13),
context=context,
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -774,7 +792,8 @@ Jamie: Congratulations! I'd love to read it.
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="Marcus",
context=context
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact from the transcript"
@ -819,7 +838,8 @@ We presented our findings to the team yesterday.
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="TestUser",
context=context
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract facts"
@ -854,7 +874,8 @@ Jamie: [teasing] We'll see who's right, my Niners pick is solid.
event_date=datetime(2024, 11, 14),
context=context,
llm_config=llm_config,
agent_name=agent_name
agent_name=agent_name,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
@ -920,7 +941,8 @@ so the algorithm learns to box out. See you next week!
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
agent_name="Marcus",
context=context
context=context,
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"

View file

@ -0,0 +1,491 @@
"""
Tests for hierarchical configuration system.
Tests config resolution hierarchy (global tenant bank),
key normalization, API endpoints, validation, and caching.
"""
import os
import pytest
from hindsight_api import MemoryEngine
from hindsight_api.config import HindsightConfig, normalize_config_dict, normalize_config_key
from hindsight_api.config_resolver import ConfigResolver
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext
# Enable bank config API for all tests in this module
os.environ["HINDSIGHT_API_ENABLE_BANK_CONFIG_API"] = "true"
class MockTenantExtension(TenantExtension):
"""Mock tenant extension for testing tenant-level config."""
def __init__(self, tenant_config: dict):
self.tenant_config = tenant_config
async def authenticate(self, context):
from hindsight_api.extensions.tenant import TenantContext
return TenantContext(schema_name="public")
async def list_tenants(self):
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema="public")]
async def get_tenant_config(self, context):
"""Return mock tenant config."""
return self.tenant_config
@pytest.mark.asyncio
async def test_config_key_normalization():
"""Test that env var keys are normalized to Python field names."""
# Test basic normalization
assert normalize_config_key("HINDSIGHT_API_LLM_PROVIDER") == "llm_provider"
assert normalize_config_key("HINDSIGHT_API_LLM_MODEL") == "llm_model"
assert normalize_config_key("HINDSIGHT_API_RETAIN_LLM_PROVIDER") == "retain_llm_provider"
# Test already normalized keys
assert normalize_config_key("llm_provider") == "llm_provider"
assert normalize_config_key("llm_model") == "llm_model"
# Test dict normalization
input_dict = {
"HINDSIGHT_API_LLM_PROVIDER": "openai",
"HINDSIGHT_API_LLM_MODEL": "gpt-4",
"llm_base_url": "https://api.openai.com",
}
expected = {"llm_provider": "openai", "llm_model": "gpt-4", "llm_base_url": "https://api.openai.com"}
assert normalize_config_dict(input_dict) == expected
@pytest.mark.asyncio
async def test_hierarchical_fields_categorization():
"""Test that fields are correctly categorized as configurable, credentials, or static."""
configurable = HindsightConfig.get_configurable_fields()
credentials = HindsightConfig.get_credential_fields()
static = HindsightConfig.get_static_fields()
# Verify no overlap between configurable and credentials
assert len(configurable & credentials) == 0, "Configurable fields should not include credentials"
# Verify configurable fields include behavioral settings (safe to modify)
assert "retain_extraction_mode" in configurable
assert "enable_observations" in configurable
assert "retain_chunk_size" in configurable
assert "retain_custom_instructions" in configurable
# Verify count is correct (only 4 fields)
assert len(configurable) == 4
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
assert "llm_base_url" in credentials
assert "retain_llm_api_key" in credentials
assert "reflect_llm_api_key" in credentials
# Verify static fields include server settings AND non-configurable LLM fields
assert "database_url" in static
assert "port" in static
assert "host" in static
assert "embeddings_provider" in static
assert "reranker_provider" in static
assert "worker_enabled" in static
assert "llm_provider" in static # Not configurable (needs presets)
assert "llm_model" in static # Not configurable (needs presets)
assert "graph_retriever" in static # Performance tuning, not configurable
assert "llm_max_concurrent" in static # Performance tuning, not configurable
@pytest.mark.asyncio
async def test_config_hierarchy_resolution(memory, request_context):
"""Test that config resolution follows global → tenant → bank hierarchy."""
bank_id = "test-hierarchy-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
# Set up mock tenant extension with tenant-level config (use configurable fields only)
tenant_config = {"retain_chunk_size": 5000, "retain_extraction_mode": "tenant-mode"}
mock_tenant = MockTenantExtension(tenant_config)
# Create config resolver with mock tenant extension
resolver = ConfigResolver(pool=memory._pool, tenant_extension=mock_tenant)
# Test 1: Global config only (no overrides)
context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
config = await resolver.get_bank_config(bank_id, context)
# Should have configurable fields from global config (NOT credentials or llm_provider/model)
assert "retain_chunk_size" in config # Configurable field
assert "llm_api_key" not in config # Credential - never exposed
assert "llm_provider" not in config # Not configurable (needs presets)
# Test 2: Add tenant-level overrides
config = await resolver.get_bank_config(bank_id, context)
# Should apply tenant overrides (only configurable fields)
assert config["retain_chunk_size"] == 5000 # Tenant override
assert config["retain_extraction_mode"] == "tenant-mode" # Tenant override
# Test 3: Add bank-level overrides (should take precedence)
await resolver.update_bank_config(
bank_id,
{"retain_chunk_size": 2000, "retain_extraction_mode": "bank-mode"}, # Override tenant settings
context,
)
# Config should reflect changes immediately (no caching)
config = await resolver.get_bank_config(bank_id, context)
# Bank overrides should take precedence over tenant
assert config["retain_chunk_size"] == 2000 # Bank override wins
assert config["retain_extraction_mode"] == "bank-mode" # Bank override wins
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_validation_rejects_static_fields(memory, request_context):
"""Test that attempting to override static fields raises ValueError."""
bank_id = "test-validation-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Test 1: Configurable fields should work
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"})
# Test 2: Static fields should raise ValueError
with pytest.raises(ValueError, match="Cannot override static"):
await resolver.update_bank_config(bank_id, {"port": 9000})
with pytest.raises(ValueError, match="Cannot override static"):
await resolver.update_bank_config(bank_id, {"database_url": "postgresql://fake"})
with pytest.raises(ValueError, match="Cannot override static"):
await resolver.update_bank_config(bank_id, {"embeddings_provider": "openai"})
# Test 3: Credential fields should raise ValueError
with pytest.raises(ValueError, match="Cannot set credential fields"):
await resolver.update_bank_config(bank_id, {"llm_api_key": "sk-fake"})
# Test 4: Non-configurable LLM fields should raise ValueError (need presets)
with pytest.raises(ValueError, match="Cannot override static"):
await resolver.update_bank_config(bank_id, {"llm_model": "gpt-4"})
# Test 5: Mix of configurable and static should fail
with pytest.raises(ValueError, match="Cannot override static"):
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "port": 9000})
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_freshness_across_updates(memory, request_context):
"""Test that config changes are immediately visible (no stale cache)."""
bank1 = "freshness-test-1"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank1, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Test 1: Initial config reflects global defaults
config1 = await resolver.get_bank_config(bank1, None)
initial_chunk_size = config1["retain_chunk_size"]
# Test 2: Update config
await resolver.update_bank_config(bank1, {"retain_chunk_size": 4000})
# Test 3: Next call should see updated value immediately (no stale cache)
config2 = await resolver.get_bank_config(bank1, None)
assert config2["retain_chunk_size"] == 4000
# Test 4: Multiple updates are all immediately visible
await resolver.update_bank_config(bank1, {"retain_chunk_size": 4500})
config3 = await resolver.get_bank_config(bank1, None)
assert config3["retain_chunk_size"] == 4500
# Test 5: Reset restores global defaults immediately
await resolver.reset_bank_config(bank1)
config4 = await resolver.get_bank_config(bank1, None)
assert config4["retain_chunk_size"] == initial_chunk_size # Back to global default
# Test 6: Each call returns a fresh config dict (not a cached reference)
config5 = await resolver.get_bank_config(bank1, None)
config6 = await resolver.get_bank_config(bank1, None)
assert config5 is not config6 # Different object instances
finally:
await memory.delete_bank(bank1, request_context=request_context)
@pytest.mark.asyncio
async def test_config_reset_to_defaults(memory, request_context):
"""Test that resetting config removes all bank-specific overrides."""
bank_id = "test-reset-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Add bank-specific overrides
await resolver.update_bank_config(
bank_id,
{
"retain_chunk_size": 5500,
"retain_extraction_mode": "custom",
"retain_custom_instructions": "Custom instructions",
},
)
# Verify overrides applied
config = await resolver.get_bank_config(bank_id, None)
assert config["retain_chunk_size"] == 5500
assert config["retain_extraction_mode"] == "custom"
assert config["retain_custom_instructions"] == "Custom instructions"
# Reset to defaults
await resolver.reset_bank_config(bank_id)
# Verify overrides removed (back to global defaults)
config_reset = await resolver.get_bank_config(bank_id, None)
assert config_reset["retain_chunk_size"] != 5500 # Should be global default
assert config_reset["retain_extraction_mode"] != "custom" # Should be global default
# Verify bank_config is empty
bank_overrides = await resolver._load_bank_config(bank_id)
assert bank_overrides == {}
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_supports_both_key_formats(memory, request_context):
"""Test that API accepts both env var and Python field formats."""
bank_id = "test-key-format-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Test 1: Python field format
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000})
config = await resolver.get_bank_config(bank_id, None)
assert config["retain_chunk_size"] == 7000
# Test 2: Env var format (should be normalized)
await resolver.update_bank_config(bank_id, {"HINDSIGHT_API_RETAIN_CHUNK_SIZE": 8000})
config = await resolver.get_bank_config(bank_id, None)
assert config["retain_chunk_size"] == 8000
# Test 3: Mixed format in same request
await resolver.update_bank_config(
bank_id,
{
"retain_chunk_size": 9000, # Python format
"HINDSIGHT_API_RETAIN_EXTRACTION_MODE": "verbose", # Env format
},
)
config = await resolver.get_bank_config(bank_id, None)
assert config["retain_chunk_size"] == 9000
assert config["retain_extraction_mode"] == "verbose"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_only_configurable_fields_stored(memory, request_context):
"""Test that only configurable fields are stored in bank config."""
bank_id = "test-filter-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Add valid configurable field
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 3500})
# Load bank config and verify only configurable fields present
bank_overrides = await resolver._load_bank_config(bank_id)
for key in bank_overrides.keys():
assert key in HindsightConfig.get_configurable_fields(), f"Non-configurable field {key} in bank config"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory, request_context):
"""
SECURITY TEST: Verify get_bank_config() only returns configurable fields (no static/credentials).
This prevents leaking sensitive system configuration like database URLs,
API keys, LLM providers/models, worker counts, etc. when retrieving bank configuration.
"""
bank_id = "test-security-bank"
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# Get bank config
config = await resolver.get_bank_config(bank_id, None)
# Get field categorizations
configurable_fields = HindsightConfig.get_configurable_fields()
credential_fields = HindsightConfig.get_credential_fields()
static_fields = HindsightConfig.get_static_fields()
# SECURITY: Verify ONLY configurable fields are returned (NO static, NO credentials)
for key in config.keys():
assert key in configurable_fields, (
f"SECURITY VIOLATION: Non-configurable field '{key}' returned by get_bank_config(). "
f"Only configurable fields should be returned to prevent leaking system config."
)
assert key not in credential_fields, (
f"SECURITY VIOLATION: Credential field '{key}' returned by get_bank_config(). "
f"Credentials must NEVER be exposed via API."
)
# SECURITY: Verify specific sensitive fields are NOT present
sensitive_fields = [
"database_url", "api_port", "host", "worker_count", # Infrastructure
"llm_api_key", "llm_base_url", # Credentials
"retain_llm_api_key", "reflect_llm_api_key", # More credentials
"llm_provider", "llm_model", # Not configurable (need presets)
]
for field in sensitive_fields:
assert field not in config, (
f"SECURITY VIOLATION: Sensitive field '{field}' returned by get_bank_config(). "
f"Must not be exposed via bank config API."
)
# Verify we have the expected configurable fields (small set)
expected_configurable = ["retain_chunk_size", "retain_extraction_mode", "enable_observations"]
for field in expected_configurable:
assert field in config, f"Expected configurable field '{field}' missing from config"
# Should have a small number of configurable fields (not hundreds)
assert len(config) < 20, f"Too many fields returned: {len(config)}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_permissions_system(memory, request_context):
"""
Test that tenant extension can control which fields banks are allowed to modify.
Tests get_allowed_config_fields() permission system.
"""
bank_id = "test-permissions-bank"
class PermissionTenantExtension(TenantExtension):
"""Mock tenant extension with configurable permissions."""
def __init__(self, allowed_fields: set[str] | None):
self.allowed_fields = allowed_fields
async def authenticate(self, context):
from hindsight_api.extensions.tenant import TenantContext
return TenantContext(schema_name="public")
async def list_tenants(self):
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema="public")]
async def get_allowed_config_fields(self, context, bank_id):
"""Return configured allowed fields."""
return self.allowed_fields
try:
# Ensure bank exists in database
await memory.get_bank_profile(bank_id, request_context=request_context)
# Test 1: None = allow all configurable fields
extension = PermissionTenantExtension(allowed_fields=None)
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
await resolver.update_bank_config(
bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"}, request_context
)
config = await resolver.get_bank_config(bank_id, request_context)
assert config["retain_chunk_size"] == 4000
assert config["retain_extraction_mode"] == "verbose"
# Reset for next test
await resolver.reset_bank_config(bank_id)
# Test 2: Specific set = only those fields allowed
extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size"})
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
# Should allow retain_chunk_size
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 5000}, request_context)
config = await resolver.get_bank_config(bank_id, request_context)
assert config["retain_chunk_size"] == 5000
# Should reject retain_extraction_mode (not in allowed list)
with pytest.raises(ValueError, match="Not allowed to modify fields"):
await resolver.update_bank_config(bank_id, {"retain_extraction_mode": "verbose"}, request_context)
# Should reject mix of allowed and disallowed
with pytest.raises(ValueError, match="Not allowed to modify fields"):
await resolver.update_bank_config(
bank_id, {"retain_chunk_size": 6000, "retain_extraction_mode": "verbose"}, request_context
)
# Reset for next test
await resolver.reset_bank_config(bank_id)
# Test 3: Empty set = no modifications allowed (read-only)
extension = PermissionTenantExtension(allowed_fields=set())
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
with pytest.raises(ValueError, match="Not allowed to modify fields"):
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000}, request_context)
# Test 4: get_bank_config should filter response based on permissions
extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size", "enable_observations"})
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
config = await resolver.get_bank_config(bank_id, request_context)
# Should only return allowed fields
assert "retain_chunk_size" in config
assert "enable_observations" in config
# Other configurable fields should be filtered out
assert "retain_extraction_mode" not in config
assert "retain_custom_instructions" not in config
finally:
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -12,9 +12,9 @@ import pytest
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config
config = get_config()
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = True
yield

View file

@ -45,7 +45,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
@ -96,7 +96,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
@ -143,7 +143,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.DefaultExtensionContext"), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
@ -200,7 +200,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.DefaultExtensionContext", side_effect=capture_context), \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
@ -242,7 +242,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run"):
@ -287,7 +287,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app", return_value=mock_app), \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run", side_effect=capture_uvicorn_run):
@ -327,7 +327,7 @@ class TestMainModuleExtensionLoading:
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
patch("hindsight_api.main.create_app") as mock_create_app, \
patch("hindsight_api.main.get_config") as mock_get_config, \
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
patch("hindsight_api.main.print_banner"), \
patch("uvicorn.run", side_effect=capture_uvicorn_run):

View file

@ -8,14 +8,14 @@ populated from the summary for backwards compatibility.
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config
from datetime import datetime, timezone
@pytest.fixture
def disable_observations():
"""Disable observations for a specific test."""
config = get_config()
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = False
yield

View file

@ -2093,7 +2093,7 @@ async def test_custom_extraction_mode():
import os
from hindsight_api import LLMConfig
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache
from hindsight_api.config import clear_config_cache, _get_raw_config
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
@ -2135,7 +2135,8 @@ If the text contains both Italian and English content, extract ONLY the Italian
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
context="team meeting notes",
llm_config=llm_config,
agent_name="TestUser"
agent_name="TestUser",
config=_get_raw_config(),
)
logger.info(f"\nExtracted {len(facts)} facts with custom mode (Italian only):")

View file

@ -387,6 +387,43 @@ impl ApiClient {
})
}
pub fn get_bank_config(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::BankConfigResponse> {
self.runtime.block_on(async {
let response = self.client.get_bank_config(bank_id, None).await?;
Ok(response.into_inner())
})
}
pub fn update_bank_config(
&self,
bank_id: &str,
updates: std::collections::HashMap<String, serde_json::Value>,
_verbose: bool,
) -> Result<types::BankConfigResponse> {
self.runtime.block_on(async {
// Convert HashMap to serde_json::Map
let updates_map: serde_json::Map<String, serde_json::Value> = updates.into_iter().collect();
let request = types::BankConfigUpdate { updates: updates_map };
let response = self.client.update_bank_config(bank_id, None, &request).await?;
Ok(response.into_inner())
})
}
pub fn reset_bank_config(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::BankConfigResponse> {
self.runtime.block_on(async {
let response = self.client.reset_bank_config(bank_id, None).await?;
Ok(response.into_inner())
})
}
// --- Tag Methods ---
pub fn list_tags(

View file

@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{anyhow, Result};
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
@ -655,3 +655,159 @@ pub fn clear_observations(
Err(e) => Err(e),
}
}
pub fn config(
client: &ApiClient,
bank_id: &str,
overrides_only: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching bank configuration..."))
} else {
None
};
let response = client.get_bank_config(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Configuration for bank '{}'", bank_id));
println!();
if overrides_only {
println!("Bank-specific overrides:");
if result.overrides.is_empty() {
println!(" (none - using defaults)");
} else {
for (key, value) in result.overrides.iter() {
println!(" {}: {:?}", key, value);
}
}
} else {
println!("Resolved configuration (with all overrides applied):");
for (key, value) in result.config.iter() {
println!(" {}: {:?}", key, value);
}
}
} else {
if overrides_only {
output::print_output(&result.overrides, output_format)?;
} else {
output::print_output(&result, output_format)?;
}
}
Ok(())
}
Err(e) => Err(e),
}
}
pub fn set_config(
client: &ApiClient,
bank_id: &str,
llm_provider: Option<String>,
llm_model: Option<String>,
llm_api_key: Option<String>,
llm_base_url: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
use std::collections::HashMap;
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(provider) = llm_provider {
updates.insert("llm_provider".to_string(), serde_json::Value::String(provider));
}
if let Some(model) = llm_model {
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
}
if let Some(api_key) = llm_api_key {
updates.insert("llm_api_key".to_string(), serde_json::Value::String(api_key));
}
if let Some(base_url) = llm_base_url {
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
}
if updates.is_empty() {
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating bank configuration..."))
} else {
None
};
let response = client.update_bank_config(bank_id, updates, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Configuration updated for bank '{}'", bank_id));
println!("\nUpdated overrides:");
for (key, value) in result.overrides.iter() {
println!(" {}: {:?}", key, value);
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
pub fn reset_config(
client: &ApiClient,
bank_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if !yes && output_format == OutputFormat::Pretty {
let confirmed = ui::prompt_confirmation(&format!(
"Reset all configuration overrides for bank '{}'?",
bank_id
))?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Resetting bank configuration..."))
} else {
None
};
let response = client.reset_bank_config(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Configuration reset to defaults for bank '{}'", bank_id));
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}

View file

@ -58,8 +58,22 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
);
}
// 404 Not Found
// 404 Not Found - check for disabled features first
if err_str.contains("404") {
if err_str.contains("Bank configuration API is disabled") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}",
"".bright_red().bold(),
"Bank configuration API is disabled".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"This feature is disabled by default for security.".bright_yellow(),
"To enable, set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true on the API server".bright_white(),
"Note:".bright_cyan(),
"This allows per-bank LLM configuration overrides via API".bright_white()
);
}
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
@ -74,8 +88,8 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
);
}
// 401/403 Authentication
if err_str.contains("401") || err_str.contains("403") {
// 401 Authentication failed
if err_str.contains("401") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
@ -90,6 +104,22 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
);
}
// 403 Forbidden
if err_str.contains("403") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
"".bright_red().bold(),
"Permission denied (403)".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"This operation is not allowed".bright_white(),
"The feature may be disabled on the server".bright_white(),
"Try:".bright_green(),
"Check server configuration or contact your administrator".bright_white()
);
}
// 500 Server Error
if err_str.contains("500") || err_str.contains("502") || err_str.contains("503") {
return format!(

View file

@ -279,6 +279,48 @@ enum BankCommands {
#[arg(short = 'y', long)]
yes: bool,
},
/// Get bank configuration (hierarchical overrides)
Config {
/// Bank ID
bank_id: String,
/// Show only bank-specific overrides (not full resolved config)
#[arg(long)]
overrides_only: bool,
},
/// Update bank configuration (set hierarchical overrides)
SetConfig {
/// Bank ID
bank_id: String,
/// LLM provider override
#[arg(long)]
llm_provider: Option<String>,
/// LLM model override
#[arg(long)]
llm_model: Option<String>,
/// LLM API key override
#[arg(long)]
llm_api_key: Option<String>,
/// LLM base URL override
#[arg(long)]
llm_base_url: Option<String>,
},
/// Reset bank configuration to defaults (remove all overrides)
ResetConfig {
/// Bank ID
bank_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
@ -776,6 +818,15 @@ fn run() -> Result<()> {
BankCommands::ClearObservations { bank_id, yes } => {
commands::bank::clear_observations(&client, &bank_id, yes, verbose, output_format)
}
BankCommands::Config { bank_id, overrides_only } => {
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
}
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
}
BankCommands::ResetConfig { bank_id, yes } => {
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
}
},
// Memory commands

View file

@ -16,6 +16,8 @@ hindsight_client_api/models/__init__.py
hindsight_client_api/models/add_background_request.py
hindsight_client_api/models/async_operation_submit_response.py
hindsight_client_api/models/background_response.py
hindsight_client_api/models/bank_config_response.py
hindsight_client_api/models/bank_config_update.py
hindsight_client_api/models/bank_list_item.py
hindsight_client_api/models/bank_list_response.py
hindsight_client_api/models/bank_profile_response.py

View file

@ -41,6 +41,8 @@ from hindsight_client_api.exceptions import ApiException
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_config_response import BankConfigResponse
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse

View file

@ -20,6 +20,8 @@ from pydantic import StrictStr
from typing import Optional
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_config_response import BankConfigResponse
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
from hindsight_client_api.models.bank_list_response import BankListResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.bank_stats_response import BankStatsResponse
@ -1495,6 +1497,284 @@ class BanksApi:
@validate_call
async def get_bank_config(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BankConfigResponse:
"""Get bank configuration
Get fully resolved configuration for a bank including all hierarchical overrides (global tenant bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_bank_config_with_http_info(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BankConfigResponse]:
"""Get bank configuration
Get fully resolved configuration for a bank including all hierarchical overrides (global tenant bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_bank_config_without_preload_content(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get bank configuration
Get fully resolved configuration for a bank including all hierarchical overrides (global tenant bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_bank_config_serialize(
self,
bank_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/v1/default/banks/{bank_id}/config',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def get_bank_profile(
self,
@ -2036,6 +2316,284 @@ class BanksApi:
@validate_call
async def reset_bank_config(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BankConfigResponse:
"""Reset bank configuration
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._reset_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def reset_bank_config_with_http_info(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BankConfigResponse]:
"""Reset bank configuration
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._reset_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def reset_bank_config_without_preload_content(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Reset bank configuration
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._reset_bank_config_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _reset_bank_config_serialize(
self,
bank_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='DELETE',
resource_path='/v1/default/banks/{bank_id}/config',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def trigger_consolidation(
self,
@ -2620,6 +3178,312 @@ class BanksApi:
@validate_call
async def update_bank_config(
self,
bank_id: StrictStr,
bank_config_update: BankConfigUpdate,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BankConfigResponse:
"""Update bank configuration
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
:param bank_id: (required)
:type bank_id: str
:param bank_config_update: (required)
:type bank_config_update: BankConfigUpdate
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_config_serialize(
bank_id=bank_id,
bank_config_update=bank_config_update,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def update_bank_config_with_http_info(
self,
bank_id: StrictStr,
bank_config_update: BankConfigUpdate,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BankConfigResponse]:
"""Update bank configuration
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
:param bank_id: (required)
:type bank_id: str
:param bank_config_update: (required)
:type bank_config_update: BankConfigUpdate
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_config_serialize(
bank_id=bank_id,
bank_config_update=bank_config_update,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def update_bank_config_without_preload_content(
self,
bank_id: StrictStr,
bank_config_update: BankConfigUpdate,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Update bank configuration
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
:param bank_id: (required)
:type bank_id: str
:param bank_config_update: (required)
:type bank_config_update: BankConfigUpdate
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_config_serialize(
bank_id=bank_id,
bank_config_update=bank_config_update,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "BankConfigResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _update_bank_config_serialize(
self,
bank_id,
bank_config_update,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
if bank_config_update is not None:
_body_params = bank_config_update
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# set the HTTP header `Content-Type`
if _content_type:
_header_params['Content-Type'] = _content_type
else:
_default_content_type = (
self.api_client.select_header_content_type(
[
'application/json'
]
)
)
if _default_content_type is not None:
_header_params['Content-Type'] = _default_content_type
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='PATCH',
resource_path='/v1/default/banks/{bank_id}/config',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def update_bank_disposition(
self,

View file

@ -17,6 +17,8 @@
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_config_response import BankConfigResponse
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse

View file

@ -0,0 +1,91 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class BankConfigResponse(BaseModel):
"""
Response model for bank configuration.
""" # noqa: E501
bank_id: StrictStr = Field(description="Bank identifier")
config: Dict[str, Any] = Field(description="Fully resolved configuration with all hierarchical overrides applied (Python field names)")
overrides: Dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
__properties: ClassVar[List[str]] = ["bank_id", "config", "overrides"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of BankConfigResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of BankConfigResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"bank_id": obj.get("bank_id"),
"config": obj.get("config"),
"overrides": obj.get("overrides")
})
return _obj

View file

@ -0,0 +1,87 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.10
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class BankConfigUpdate(BaseModel):
"""
Request model for updating bank configuration.
""" # noqa: E501
updates: Dict[str, Any] = Field(description="Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank.")
__properties: ClassVar[List[str]] = ["updates"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of BankConfigUpdate from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of BankConfigUpdate from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"updates": obj.get("updates")
})
return _obj

View file

@ -29,7 +29,8 @@ class FeaturesInfo(BaseModel):
observations: StrictBool = Field(description="Whether observations (auto-consolidation) are enabled")
mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: StrictBool = Field(description="Whether the background worker is enabled")
__properties: ClassVar[List[str]] = ["observations", "mcp", "worker"]
bank_config_api: StrictBool = Field(description="Whether per-bank configuration API is enabled")
__properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api"]
model_config = ConfigDict(
populate_by_name=True,
@ -84,7 +85,8 @@ class FeaturesInfo(BaseModel):
_obj = cls.model_validate({
"observations": obj.get("observations"),
"mcp": obj.get("mcp"),
"worker": obj.get("worker")
"worker": obj.get("worker"),
"bank_config_api": obj.get("bank_config_api")
})
return _obj

View file

@ -39,6 +39,9 @@ import type {
GetAgentStatsData,
GetAgentStatsErrors,
GetAgentStatsResponses,
GetBankConfigData,
GetBankConfigErrors,
GetBankConfigResponses,
GetBankProfileData,
GetBankProfileErrors,
GetBankProfileResponses,
@ -108,12 +111,18 @@ import type {
RegenerateEntityObservationsData,
RegenerateEntityObservationsErrors,
RegenerateEntityObservationsResponses,
ResetBankConfigData,
ResetBankConfigErrors,
ResetBankConfigResponses,
RetainMemoriesData,
RetainMemoriesErrors,
RetainMemoriesResponses,
TriggerConsolidationData,
TriggerConsolidationErrors,
TriggerConsolidationResponses,
UpdateBankConfigData,
UpdateBankConfigErrors,
UpdateBankConfigResponses,
UpdateBankData,
UpdateBankDispositionData,
UpdateBankDispositionErrors,
@ -808,6 +817,55 @@ export const clearObservations = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/observations", ...options });
/**
* Reset bank configuration
*
* Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
*/
export const resetBankConfig = <ThrowOnError extends boolean = false>(
options: Options<ResetBankConfigData, ThrowOnError>,
) =>
(options.client ?? client).delete<
ResetBankConfigResponses,
ResetBankConfigErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/config", ...options });
/**
* Get bank configuration
*
* Get fully resolved configuration for a bank including all hierarchical overrides (global tenant bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
*/
export const getBankConfig = <ThrowOnError extends boolean = false>(
options: Options<GetBankConfigData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetBankConfigResponses,
GetBankConfigErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/config", ...options });
/**
* Update bank configuration
*
* Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
*/
export const updateBankConfig = <ThrowOnError extends boolean = false>(
options: Options<UpdateBankConfigData, ThrowOnError>,
) =>
(options.client ?? client).patch<
UpdateBankConfigResponses,
UpdateBankConfigErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/config",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* Trigger consolidation
*

View file

@ -59,6 +59,52 @@ export type BackgroundResponse = {
disposition?: DispositionTraits | null;
};
/**
* BankConfigResponse
*
* Response model for bank configuration.
*/
export type BankConfigResponse = {
/**
* Bank Id
*
* Bank identifier
*/
bank_id: string;
/**
* Config
*
* Fully resolved configuration with all hierarchical overrides applied (Python field names)
*/
config: {
[key: string]: unknown;
};
/**
* Overrides
*
* Bank-specific configuration overrides only (Python field names)
*/
overrides: {
[key: string]: unknown;
};
};
/**
* BankConfigUpdate
*
* Request model for updating bank configuration.
*/
export type BankConfigUpdate = {
/**
* Updates
*
* Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank.
*/
updates: {
[key: string]: unknown;
};
};
/**
* BankListItem
*
@ -816,6 +862,12 @@ export type FeaturesInfo = {
* Whether the background worker is enabled
*/
worker: boolean;
/**
* Bank Config Api
*
* Whether per-bank configuration API is enabled
*/
bank_config_api: boolean;
};
/**
@ -3426,6 +3478,119 @@ export type ClearObservationsResponses = {
export type ClearObservationsResponse =
ClearObservationsResponses[keyof ClearObservationsResponses];
export type ResetBankConfigData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/config";
};
export type ResetBankConfigErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ResetBankConfigError =
ResetBankConfigErrors[keyof ResetBankConfigErrors];
export type ResetBankConfigResponses = {
/**
* Successful Response
*/
200: BankConfigResponse;
};
export type ResetBankConfigResponse =
ResetBankConfigResponses[keyof ResetBankConfigResponses];
export type GetBankConfigData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/config";
};
export type GetBankConfigErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetBankConfigError = GetBankConfigErrors[keyof GetBankConfigErrors];
export type GetBankConfigResponses = {
/**
* Successful Response
*/
200: BankConfigResponse;
};
export type GetBankConfigResponse =
GetBankConfigResponses[keyof GetBankConfigResponses];
export type UpdateBankConfigData = {
body: BankConfigUpdate;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/config";
};
export type UpdateBankConfigErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateBankConfigError =
UpdateBankConfigErrors[keyof UpdateBankConfigErrors];
export type UpdateBankConfigResponses = {
/**
* Successful Response
*/
200: BankConfigResponse;
};
export type UpdateBankConfigResponse =
UpdateBankConfigResponses[keyof UpdateBankConfigResponses];
export type TriggerConsolidationData = {
body?: never;
headers?: {

View file

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server";
import { lowLevelClient, sdk } from "@/lib/hindsight-client";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ bankId: string }> }
) {
try {
const { bankId } = await params;
const response = await sdk.getBankConfig({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (!response.data) {
console.error("[Bank Config API] No data in response", { response, error: response.error });
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error fetching bank config:", error);
return NextResponse.json({ error: "Failed to fetch bank config" }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ bankId: string }> }
) {
try {
const { bankId } = await params;
const body = await request.json();
const { updates } = body;
const response = await sdk.updateBankConfig({
client: lowLevelClient,
path: { bank_id: bankId },
body: { updates },
});
if (!response.data) {
console.error("[Bank Config API] No data in response", { response, error: response.error });
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error updating bank config:", error);
return NextResponse.json({ error: "Failed to update bank config" }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ bankId: string }> }
) {
try {
const { bankId } = await params;
const response = await sdk.resetBankConfig({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (!response.data) {
console.error("[Bank Config API] No data in response", { response, error: response.error });
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error resetting bank config:", error);
return NextResponse.json({ error: "Failed to reset bank config" }, { status: 500 });
}
}

View file

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { BankSelector } from "@/components/bank-selector";
import { Sidebar } from "@/components/sidebar";
@ -9,22 +10,56 @@ import { EntitiesView } from "@/components/entities-view";
import { ThinkView } from "@/components/think-view";
import { SearchDebugView } from "@/components/search-debug-view";
import { BankProfileView } from "@/components/bank-profile-view";
import { BankConfigView } from "@/components/bank-config-view";
import { BankStatsView } from "@/components/bank-stats-view";
import { BankOperationsView } from "@/components/bank-operations-view";
import { MentalModelsView } from "@/components/mental-models-view";
import { useFeatures } from "@/lib/features-context";
import { useBank } from "@/lib/bank-context";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Brain, Trash2, Loader2, MoreVertical, Pencil } from "lucide-react";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
type DataSubTab = "world" | "experience" | "observations" | "mental-models";
type BankConfigTab = "general" | "configuration";
export default function BankPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const { features } = useFeatures();
const { currentBank: bankId, setCurrentBank, loadBanks } = useBank();
const bankId = params.bankId as string;
const view = (searchParams.get("view") || "profile") as NavItem;
const subTab = (searchParams.get("subTab") || "world") as DataSubTab;
const bankConfigTab = (searchParams.get("bankConfigTab") || "general") as BankConfigTab;
const observationsEnabled = features?.observations ?? false;
const bankConfigEnabled = features?.bank_config_api ?? false;
// Bank actions state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showClearObservationsDialog, setShowClearObservationsDialog] = useState(false);
const [isClearingObservations, setIsClearingObservations] = useState(false);
const [isConsolidating, setIsConsolidating] = useState(false);
const handleTabChange = (tab: NavItem) => {
router.push(`/banks/${bankId}?view=${tab}`);
@ -34,6 +69,58 @@ export default function BankPage() {
router.push(`/banks/${bankId}?view=data&subTab=${newSubTab}`);
};
const handleBankConfigTabChange = (newTab: BankConfigTab) => {
router.push(`/banks/${bankId}?view=profile&bankConfigTab=${newTab}`);
};
const handleDeleteBank = async () => {
if (!bankId) return;
setIsDeleting(true);
try {
await client.deleteBank(bankId);
setShowDeleteDialog(false);
setCurrentBank(null);
await loadBanks();
router.push("/");
} catch (error) {
console.error("Error deleting bank:", error);
alert("Error deleting bank: " + (error as Error).message);
} finally {
setIsDeleting(false);
}
};
const handleClearObservations = async () => {
if (!bankId) return;
setIsClearingObservations(true);
try {
const result = await client.clearObservations(bankId);
setShowClearObservationsDialog(false);
alert(result.message || "Observations cleared successfully");
} catch (error) {
console.error("Error clearing observations:", error);
alert("Error clearing observations: " + (error as Error).message);
} finally {
setIsClearingObservations(false);
}
};
const handleTriggerConsolidation = async () => {
if (!bankId) return;
setIsConsolidating(true);
try {
await client.triggerConsolidation(bankId);
} catch (error) {
console.error("Error triggering consolidation:", error);
alert("Error triggering consolidation: " + (error as Error).message);
} finally {
setIsConsolidating(false);
}
};
return (
<div className="min-h-screen bg-background flex flex-col">
<BankSelector />
@ -43,15 +130,125 @@ export default function BankPage() {
<main className="flex-1 overflow-y-auto">
<div className="p-6">
{/* Profile Tab */}
{/* Bank Configuration Tab */}
{view === "profile" && (
<div>
<h1 className="text-3xl font-bold mb-2 text-foreground">Bank Profile</h1>
<p className="text-muted-foreground mb-6">
View and edit the memory bank profile, disposition traits, and background
information.
</p>
<BankProfileView />
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold mb-2 text-foreground">Bank Configuration</h1>
<p className="text-muted-foreground">
Manage bank settings, profile, and operations.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Actions
<MoreVertical className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={handleTriggerConsolidation}
disabled={isConsolidating || !observationsEnabled}
title={
!observationsEnabled ? "Observations feature is not enabled" : undefined
}
>
{isConsolidating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Brain className="w-4 h-4 mr-2" />
)}
{isConsolidating ? "Consolidating..." : "Run Consolidation"}
{!observationsEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowClearObservationsDialog(true)}
disabled={!observationsEnabled}
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
title={
!observationsEnabled ? "Observations feature is not enabled" : undefined
}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear Observations
{!observationsEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Bank
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Sub-tabs */}
<div className="mb-6 border-b border-border">
<div className="flex gap-1">
<button
onClick={() => handleBankConfigTabChange("general")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
bankConfigTab === "general"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
General
{bankConfigTab === "general" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => handleBankConfigTabChange("configuration")}
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
bankConfigTab === "configuration"
? "text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Configuration
{bankConfigTab === "configuration" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
</div>
{/* Tab content */}
<div>
{bankConfigTab === "general" && (
<div>
<p className="text-sm text-muted-foreground mb-4">
Overview statistics and background operations for this memory bank.
</p>
<div className="space-y-6">
<BankStatsView />
<BankOperationsView />
</div>
</div>
)}
{bankConfigTab === "configuration" && (
<div>
<p className="text-sm text-muted-foreground mb-6">
Configure disposition traits, mission, directives, and behavioral settings
for this bank.
</p>
<div className="space-y-6">
<BankProfileView />
{bankConfigEnabled && <BankConfigView />}
</div>
</div>
)}
</div>
</div>
)}
@ -242,6 +439,88 @@ export default function BankPage() {
</div>
</main>
</div>
{/* Delete Bank Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Memory Bank</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Are you sure you want to delete the memory bank{" "}
<span className="font-semibold text-foreground">{bankId}</span>?
</p>
<p className="text-red-600 dark:text-red-400 font-medium">
This action cannot be undone. All memories, entities, documents, and the bank
profile will be permanently deleted.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteBank}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete Bank
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Clear Observations Confirmation Dialog */}
<AlertDialog open={showClearObservationsDialog} onOpenChange={setShowClearObservationsDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Observations</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Are you sure you want to clear all observations for{" "}
<span className="font-semibold text-foreground">{bankId}</span>?
</p>
<p className="text-amber-600 dark:text-amber-400 font-medium">
This will delete all consolidated knowledge. Observations will be regenerated the
next time consolidation runs.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isClearingObservations}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClearObservations}
disabled={isClearingObservations}
className="bg-amber-500 text-white hover:bg-amber-600"
>
{isClearingObservations ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Clearing...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Clear Observations
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,480 @@
"use client";
import { useState, useEffect } from "react";
import { useBank } from "@/lib/bank-context";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Loader2, AlertCircle, CheckCircle2, Pencil, RotateCcw, MoreVertical } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
// Field metadata for UI rendering
const FIELD_CATEGORIES = {
retention: {
title: "Retention Settings",
description: "Control how memories are extracted and stored",
fields: {
retain_chunk_size: {
label: "Chunk Size",
type: "number",
description: "Size of text chunks for processing (tokens)",
min: 500,
max: 8000,
},
retain_extraction_mode: {
label: "Extraction Mode",
type: "select",
description: "How to extract facts from content",
options: ["concise", "verbose", "custom"],
},
retain_custom_instructions: {
label: "Custom Instructions",
type: "textarea",
description:
"Custom instructions for fact extraction (requires retain_extraction_mode='custom')",
placeholder: "Focus on technical details and implementation specifics...",
rows: 3,
},
},
},
consolidation: {
title: "Consolidation Settings",
description: "Control observation synthesis",
fields: {
enable_observations: {
label: "Enable Observations",
type: "boolean",
description: "Enable automatic consolidation of facts into observations",
},
},
},
};
export function BankConfigView() {
const { currentBank: bankId } = useBank();
const [loading, setLoading] = useState(true);
const [config, setConfig] = useState<Record<string, any>>({});
const [overrides, setOverrides] = useState<Record<string, any>>({});
const [showEditDialog, setShowEditDialog] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (bankId) {
loadConfig();
}
}, [bankId]);
const loadConfig = async () => {
if (!bankId) return;
setLoading(true);
try {
const response = await client.getBankConfig(bankId);
setConfig(response.config);
setOverrides(response.overrides);
} catch (err: any) {
console.error("Failed to load config:", err);
} finally {
setLoading(false);
}
};
const handleReset = () => {
setShowResetDialog(true);
};
const confirmReset = async () => {
if (!bankId) return;
setResetting(true);
try {
await client.resetBankConfig(bankId);
await loadConfig();
setShowResetDialog(false);
} catch (err: any) {
console.error("Failed to reset config:", err);
alert("Error resetting config: " + err.message);
} finally {
setResetting(false);
}
};
const renderReadOnlyField = (fieldKey: string, fieldMeta: any) => {
const value = config[fieldKey];
return (
<div
key={fieldKey}
className="flex items-start justify-between gap-4 p-3 border border-border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium font-mono">{fieldKey}</div>
{fieldMeta.description && (
<p className="text-xs text-muted-foreground mt-0.5">{fieldMeta.description}</p>
)}
</div>
<div className="text-sm text-foreground font-mono flex-shrink-0">
{fieldMeta.type === "boolean" ? (
<span className={value ? "text-green-600" : "text-muted-foreground"}>
{value ? "Enabled" : "Disabled"}
</span>
) : fieldMeta.type === "textarea" ? (
<span className="text-muted-foreground italic">
{value ? `${value.substring(0, 50)}${value.length > 50 ? "..." : ""}` : "Not set"}
</span>
) : (
value || <span className="text-muted-foreground italic">Not set</span>
)}
</div>
</div>
);
};
if (!bankId) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No bank selected</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Configuration Settings</CardTitle>
<CardDescription className="text-xs">
Behavioral parameters for this memory bank
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" disabled={resetting}>
{resetting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="space-y-6">
{Object.entries(FIELD_CATEGORIES).map(([catKey, category]) => (
<div key={catKey}>
<div className="mb-3">
<h3 className="text-sm font-semibold">{category.title}</h3>
<p className="text-xs text-muted-foreground">{category.description}</p>
</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
{Object.entries(category.fields).map(([fieldKey, fieldMeta]) =>
renderReadOnlyField(fieldKey, fieldMeta)
)}
</div>
</div>
))}
</CardContent>
</Card>
{showEditDialog && (
<ConfigEditDialog
bankId={bankId}
initialConfig={config}
overrides={overrides}
onClose={() => setShowEditDialog(false)}
onSaved={() => {
loadConfig();
setShowEditDialog(false);
}}
/>
)}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset Configuration</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to reset all configuration overrides to defaults? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={resetting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset} disabled={resetting}>
{resetting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Resetting...
</>
) : (
"Reset to Defaults"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// Edit dialog component
function ConfigEditDialog({
bankId,
initialConfig,
overrides,
onClose,
onSaved,
}: {
bankId: string;
initialConfig: Record<string, any>;
overrides: Record<string, any>;
onClose: () => void;
onSaved: () => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState(initialConfig);
const handleFieldChange = (field: string, value: any) => {
setConfig({ ...config, [field]: value });
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const updates: Record<string, any> = {};
Object.keys(config).forEach((key) => {
const isConfigurable = Object.values(FIELD_CATEGORIES).some((cat) =>
Object.keys(cat.fields).includes(key)
);
if (isConfigurable) {
updates[key] = config[key];
}
});
await client.updateBankConfig(bankId, updates);
onSaved();
} catch (err: any) {
console.error("Failed to save config:", err);
setError(err.message || "Failed to save configuration");
setSaving(false);
}
};
const renderField = (fieldKey: string, fieldMeta: any) => {
const value = config[fieldKey];
if (fieldMeta.type === "boolean") {
return (
<div key={fieldKey} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<Label htmlFor={fieldKey} className="font-mono">
{fieldKey}
</Label>
{fieldMeta.description && (
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
)}
</div>
<button
onClick={() => handleFieldChange(fieldKey, !value)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
value ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
);
}
if (fieldMeta.type === "select") {
return (
<div key={fieldKey} className="space-y-2">
<Label htmlFor={fieldKey} className="font-mono">
{fieldKey}
</Label>
{fieldMeta.description && (
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
)}
<Select
value={value?.toString()}
onValueChange={(val) => handleFieldChange(fieldKey, val)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldMeta.options.map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (fieldMeta.type === "textarea") {
return (
<div key={fieldKey} className="space-y-2">
<Label htmlFor={fieldKey} className="font-mono">
{fieldKey}
</Label>
{fieldMeta.description && (
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
)}
<Textarea
id={fieldKey}
value={value || ""}
onChange={(e) => handleFieldChange(fieldKey, e.target.value || null)}
placeholder={fieldMeta.placeholder}
rows={fieldMeta.rows || 3}
className="font-mono text-sm"
/>
</div>
);
}
// number or text
return (
<div key={fieldKey} className="space-y-2">
<Label htmlFor={fieldKey} className="font-mono">
{fieldKey}
</Label>
{fieldMeta.description && (
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
)}
<Input
id={fieldKey}
type={fieldMeta.type || "text"}
value={value ?? ""}
onChange={(e) =>
handleFieldChange(
fieldKey,
fieldMeta.type === "number" ? parseFloat(e.target.value) : e.target.value
)
}
min={fieldMeta.min}
max={fieldMeta.max}
/>
</div>
);
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Configuration</DialogTitle>
<DialogDescription>
Customize behavioral settings for this bank. Changes only affect this bank and override
global defaults.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{Object.entries(FIELD_CATEGORIES).map(([catKey, category]) => (
<div key={catKey} className="space-y-4">
<div>
<h3 className="text-sm font-semibold">{category.title}</h3>
<p className="text-xs text-muted-foreground">{category.description}</p>
</div>
<div className="grid gap-4">
{Object.entries(category.fields).map(([fieldKey, fieldMeta]) =>
renderField(fieldKey, fieldMeta)
)}
</div>
</div>
))}
</div>
<DialogFooter>
<Button onClick={onClose} variant="outline" disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import { useState, useEffect } from "react";
import { useBank } from "@/lib/bank-context";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RefreshCw, Clock, AlertCircle, CheckCircle, Loader2, X } from "lucide-react";
interface Operation {
id: string;
task_type: string;
items_count: number;
document_id: string | null;
created_at: string;
status: string;
error_message: string | null;
}
export function BankOperationsView() {
const { currentBank } = useBank();
const [operations, setOperations] = useState<Operation[]>([]);
const [totalOperations, setTotalOperations] = useState(0);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [limit] = useState(10);
const [offset, setOffset] = useState(0);
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadOperations = async (
newStatusFilter: string | null = statusFilter,
newOffset: number = offset
) => {
if (!currentBank) return;
setLoading(true);
try {
const opsData = await client.listOperations(currentBank, {
status: newStatusFilter || undefined,
limit,
offset: newOffset,
});
setOperations(opsData.operations || []);
setTotalOperations(opsData.total || 0);
} catch (error) {
console.error("Error loading operations:", error);
} finally {
setLoading(false);
}
};
const handleFilterChange = (newFilter: string | null) => {
setStatusFilter(newFilter);
setOffset(0);
loadOperations(newFilter, 0);
};
const handlePageChange = (newOffset: number) => {
setOffset(newOffset);
loadOperations(statusFilter, newOffset);
};
const handleCancelOperation = async (operationId: string) => {
if (!currentBank) return;
setCancellingOpId(operationId);
try {
await client.cancelOperation(currentBank, operationId);
await loadOperations();
} catch (error) {
console.error("Error cancelling operation:", error);
alert("Error cancelling operation: " + (error as Error).message);
} finally {
setCancellingOpId(null);
}
};
useEffect(() => {
if (currentBank) {
loadOperations();
// Refresh operations every 5 seconds
const interval = setInterval(() => loadOperations(), 5000);
return () => clearInterval(interval);
}
}, [currentBank]);
if (!currentBank) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Background Operations</h3>
<button
onClick={() => loadOperations()}
className="p-1 rounded hover:bg-muted transition-colors"
title="Refresh operations"
disabled={loading}
>
<RefreshCw
className={`w-4 h-4 text-muted-foreground hover:text-foreground ${loading ? "animate-spin" : ""}`}
/>
</button>
</div>
<p className="text-sm text-muted-foreground">
{totalOperations} operation{totalOperations !== 1 ? "s" : ""}
{statusFilter ? ` (${statusFilter})` : ""}
</p>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{[
{ value: null, label: "All" },
{ value: "pending", label: "Pending" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
].map((filter) => (
<button
key={filter.value ?? "all"}
onClick={() => handleFilterChange(filter.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
statusFilter === filter.value
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{filter.label}
</button>
))}
</div>
</div>
<div>
{operations.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.map((op) => (
<TableRow key={op.id} className={op.status === "failed" ? "bg-red-500/5" : ""}>
<TableCell className="font-mono text-xs text-muted-foreground">
{op.id.substring(0, 8)}
</TableCell>
<TableCell className="font-medium">{op.task_type}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(op.created_at).toLocaleString()}
</TableCell>
<TableCell>
{op.status === "pending" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Clock className="w-3 h-3" />
pending
</span>
)}
{op.status === "failed" && (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
title={op.error_message ?? undefined}
>
<AlertCircle className="w-3 h-3" />
failed
</span>
)}
{op.status === "completed" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle className="w-3 h-3" />
completed
</span>
)}
</TableCell>
<TableCell>
{op.status === "pending" && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
onClick={() => handleCancelOperation(op.id)}
disabled={cancellingOpId === op.id}
>
{cancellingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3 mr-1" />
)}
{cancellingOpId === op.id ? "" : "Cancel"}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalOperations > limit && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Showing {offset + 1}-{Math.min(offset + limit, totalOperations)} of{" "}
{totalOperations}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.max(0, offset - limit))}
disabled={offset === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(offset + limit)}
disabled={offset + limit >= totalOperations}
>
Next
</Button>
</div>
</div>
)}
</>
) : (
<p className="text-muted-foreground text-center py-8 text-sm">
No {statusFilter ? `${statusFilter} ` : ""}operations
</p>
)}
</div>
</div>
);
}

View file

@ -151,66 +151,6 @@ const TRAIT_LABELS: Record<
},
};
function DispositionEditor({
disposition,
editMode,
editDisposition,
onEditChange,
}: {
disposition: DispositionTraits;
editMode: boolean;
editDisposition: DispositionTraits;
onEditChange: (trait: keyof DispositionTraits, value: number) => void;
}) {
const data = editMode ? editDisposition : disposition;
return (
<div className="space-y-4">
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
<div key={trait} className="space-y-2">
<div className="flex justify-between items-center">
<div>
<label className="text-sm font-medium text-foreground">
{TRAIT_LABELS[trait].label}
</label>
<p className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].description}</p>
</div>
<span className="text-sm font-bold text-primary">{data[trait]}/5</span>
</div>
{editMode ? (
<>
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>{TRAIT_LABELS[trait].lowLabel}</span>
<span>{TRAIT_LABELS[trait].highLabel}</span>
</div>
<input
type="range"
min="1"
max="5"
step="1"
value={editDisposition[trait]}
onChange={(e) => onEditChange(trait, parseInt(e.target.value))}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
</>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].lowLabel}</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${((data[trait] - 1) / 4) * 100}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].highLabel}</span>
</div>
)}
</div>
))}
</div>
);
}
export function BankProfileView() {
const router = useRouter();
const { currentBank, setCurrentBank, loadBanks } = useBank();
@ -223,8 +163,8 @@ export function BankProfileView() {
const [directives, setDirectives] = useState<Directive[]>([]);
const [mentalModelsCount, setMentalModelsCount] = useState(0);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
const [showDispositionDialog, setShowDispositionDialog] = useState(false);
const [showMissionDialog, setShowMissionDialog] = useState(false);
// Directive state
const [showCreateDirective, setShowCreateDirective] = useState(false);
@ -235,12 +175,6 @@ export function BankProfileView() {
} | null>(null);
const [deletingDirective, setDeletingDirective] = useState(false);
// Ref to track editMode for polling (avoids stale closure)
const editModeRef = useRef(editMode);
useEffect(() => {
editModeRef.current = editMode;
}, [editMode]);
// Delete state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@ -258,14 +192,6 @@ export function BankProfileView() {
const [opsOffset, setOpsOffset] = useState(0);
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
// Edit state
const [editMission, setEditMission] = useState("");
const [editDisposition, setEditDisposition] = useState<DispositionTraits>({
skepticism: 3,
literalism: 3,
empathy: 3,
});
const loadOperations = async (
statusFilter: string | null = opsStatusFilter,
offset: number = opsOffset
@ -319,12 +245,6 @@ export function BankProfileView() {
setDirectives(directivesData.items || []);
setMentalModelsCount(mentalModelsData.items?.length || 0);
await loadOperations();
// Only initialize edit state when not in edit mode
if (!editModeRef.current) {
setEditMission(profileData.mission || "");
setEditDisposition(profileData.disposition);
}
} catch (error) {
console.error("Error loading bank profile:", error);
alert("Error loading bank profile: " + (error as Error).message);
@ -333,33 +253,6 @@ export function BankProfileView() {
}
};
const handleSave = async () => {
if (!currentBank) return;
setSaving(true);
try {
await client.updateBankProfile(currentBank, {
mission: editMission,
disposition: editDisposition,
});
await loadData();
setEditMode(false);
} catch (error) {
console.error("Error saving bank profile:", error);
alert("Error saving bank profile: " + (error as Error).message);
} finally {
setSaving(false);
}
};
const handleCancel = () => {
if (profile) {
setEditMission(profile.mission || "");
setEditDisposition(profile.disposition);
}
setEditMode(false);
};
const handleDeleteBank = async () => {
if (!currentBank) return;
@ -501,238 +394,58 @@ export function BankProfileView() {
return (
<div className="space-y-6">
{/* Header with actions */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-foreground">{profile?.name || currentBank}</h2>
<p className="text-sm text-muted-foreground font-mono">{currentBank}</p>
</div>
<div className="flex gap-2">
{editMode ? (
<>
<Button onClick={handleCancel} variant="secondary" disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Actions
<MoreVertical className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => setEditMode(true)}>
<Pencil className="w-4 h-4 mr-2" />
Edit Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleTriggerConsolidation}
disabled={isConsolidating || !observationsEnabled}
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
>
{isConsolidating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Brain className="w-4 h-4 mr-2" />
)}
{isConsolidating ? "Consolidating..." : "Run Consolidation"}
{!observationsEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowClearObservationsDialog(true)}
disabled={!observationsEnabled}
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear Observations
{!observationsEnabled && (
<span className="ml-auto text-xs text-muted-foreground">Off</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Bank
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Stats Overview - Compact cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-600/5 border-blue-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20">
<Database className="w-5 h-5 text-blue-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Memories</p>
<p className="text-2xl font-bold text-foreground">{stats.total_nodes}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-600/5 border-purple-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-500/20">
<Link2 className="w-5 h-5 text-purple-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Links</p>
<p className="text-2xl font-bold text-foreground">{stats.total_links}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-emerald-500/10 to-emerald-600/5 border-emerald-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/20">
<FolderOpen className="w-5 h-5 text-emerald-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Documents</p>
<p className="text-2xl font-bold text-foreground">{stats.total_documents}</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`bg-gradient-to-br ${stats.pending_operations > 0 ? "from-amber-500/10 to-amber-600/5 border-amber-500/20" : "from-slate-500/10 to-slate-600/5 border-slate-500/20"}`}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-lg ${stats.pending_operations > 0 ? "bg-amber-500/20" : "bg-slate-500/20"}`}
>
<Activity
className={`w-5 h-5 ${stats.pending_operations > 0 ? "text-amber-500 animate-pulse" : "text-slate-500"}`}
/>
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Pending</p>
<p className="text-2xl font-bold text-foreground">{stats.pending_operations}</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Memory Type Breakdown */}
{stats && (
<div className="grid grid-cols-5 gap-3">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-blue-600 dark:text-blue-400 font-semibold uppercase tracking-wide">
World Facts
</p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{stats.nodes_by_fact_type?.world || 0}
</p>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wide">
Experience
</p>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mt-1">
{stats.nodes_by_fact_type?.experience || 0}
</p>
</div>
<div
className={`rounded-xl p-4 text-center ${
observationsEnabled
? "bg-amber-500/10 border border-amber-500/20"
: "bg-muted/50 border border-muted"
}`}
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
>
<p
className={`text-xs font-semibold uppercase tracking-wide ${
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
}`}
>
Observations
{!observationsEnabled && <span className="ml-1 normal-case">(Off)</span>}
</p>
<p
className={`text-2xl font-bold mt-1 ${
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
}`}
>
{observationsEnabled ? stats.total_mental_models || 0 : "—"}
</p>
</div>
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-semibold uppercase tracking-wide">
Mental Models
</p>
<p className="text-2xl font-bold text-cyan-600 dark:text-cyan-400 mt-1">
{mentalModelsCount}
</p>
</div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wide">
Directives
</p>
<p className="text-2xl font-bold text-rose-600 dark:text-rose-400 mt-1">
{directives.length}
</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Disposition Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg">
<Brain className="w-5 h-5 text-primary" />
Disposition Profile
</CardTitle>
<CardDescription>
Traits that shape how observations are formed via Reflect
</CardDescription>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Brain className="w-5 h-5 text-primary" />
Disposition Profile
</CardTitle>
<CardDescription>Traits that shape the reasoning and perspective</CardDescription>
</div>
<Button onClick={() => setShowDispositionDialog(true)} variant="ghost" size="sm">
<Pencil className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{profile && (
<DispositionEditor
disposition={profile.disposition}
editMode={editMode}
editDisposition={editDisposition}
onEditChange={(trait, value) =>
setEditDisposition((prev) => ({ ...prev, [trait]: value }))
}
/>
<div className="space-y-4">
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
<div key={trait} className="space-y-2">
<div className="flex justify-between items-center">
<div>
<label className="text-sm font-medium text-foreground">
{TRAIT_LABELS[trait].label}
</label>
<p className="text-xs text-muted-foreground">
{TRAIT_LABELS[trait].description}
</p>
</div>
<span className="text-sm font-bold text-primary">
{profile.disposition[trait]}/5
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{TRAIT_LABELS[trait].lowLabel}
</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${((profile.disposition[trait] - 1) / 4) * 100}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{TRAIT_LABELS[trait].highLabel}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
@ -740,30 +453,26 @@ export function BankProfileView() {
{/* Mission */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg">
<Target className="w-5 h-5 text-primary" />
Mission
</CardTitle>
<CardDescription>
Who the agent is and what they&apos;re trying to accomplish. Used for mental models
and reflect.
</CardDescription>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Target className="w-5 h-5 text-primary" />
Mission
</CardTitle>
<CardDescription>
Affects how observations, reflect, and mental models are created
</CardDescription>
</div>
<Button onClick={() => setShowMissionDialog(true)} variant="ghost" size="sm">
<Pencil className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{editMode ? (
<Textarea
value={editMission}
onChange={(e) => setEditMission(e.target.value)}
placeholder="e.g., I am a PM for the engineering team. I help coordinate sprints and track project progress..."
rows={6}
className="resize-none"
/>
) : (
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{profile?.mission ||
"No mission set. Set a mission to derive structural mental models and personalize reflect responses."}
</p>
)}
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{profile?.mission ||
"No mission set. Set a mission to derive structural mental models and personalize reflect responses."}
</p>
</CardContent>
</Card>
</div>
@ -841,158 +550,6 @@ export function BankProfileView() {
</CardContent>
</Card>
{/* Operations Section */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Activity className="w-5 h-5 text-primary" />
Background Operations
<button
onClick={() => loadOperations()}
className="p-1 rounded hover:bg-muted transition-colors"
title="Refresh operations"
>
<RefreshCw className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
</CardTitle>
<CardDescription>
{totalOperations} operation{totalOperations !== 1 ? "s" : ""}
{opsStatusFilter ? ` (${opsStatusFilter})` : ""}
</CardDescription>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{[
{ value: null, label: "All" },
{ value: "pending", label: "Pending" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
].map((filter) => (
<button
key={filter.value ?? "all"}
onClick={() => handleOpsFilterChange(filter.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
opsStatusFilter === filter.value
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{filter.label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{operations.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.map((op) => (
<TableRow
key={op.id}
className={op.status === "failed" ? "bg-red-500/5" : ""}
>
<TableCell className="font-mono text-xs text-muted-foreground">
{op.id.substring(0, 8)}
</TableCell>
<TableCell className="font-medium">{op.task_type}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(op.created_at).toLocaleString()}
</TableCell>
<TableCell>
{op.status === "pending" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Clock className="w-3 h-3" />
pending
</span>
)}
{op.status === "failed" && (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
title={op.error_message ?? undefined}
>
<AlertCircle className="w-3 h-3" />
failed
</span>
)}
{op.status === "completed" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle className="w-3 h-3" />
completed
</span>
)}
</TableCell>
<TableCell>
{op.status === "pending" && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
onClick={() => handleCancelOperation(op.id)}
disabled={cancellingOpId === op.id}
>
{cancellingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3 mr-1" />
)}
{cancellingOpId === op.id ? "" : "Cancel"}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalOperations > opsLimit && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Showing {opsOffset + 1}-{Math.min(opsOffset + opsLimit, totalOperations)} of{" "}
{totalOperations}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleOpsPageChange(Math.max(0, opsOffset - opsLimit))}
disabled={opsOffset === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpsPageChange(opsOffset + opsLimit)}
disabled={opsOffset + opsLimit >= totalOperations}
>
Next
</Button>
</div>
</div>
)}
</>
) : (
<p className="text-muted-foreground text-center py-8 text-sm">
No {opsStatusFilter ? `${opsStatusFilter} ` : ""}operations
</p>
)}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
@ -1142,10 +699,197 @@ export function BankProfileView() {
}}
/>
)}
{/* Disposition Edit Dialog */}
{showDispositionDialog && profile && (
<DispositionEditDialog
disposition={profile.disposition}
onClose={() => setShowDispositionDialog(false)}
onSaved={async () => {
await loadData();
setShowDispositionDialog(false);
}}
/>
)}
{/* Mission Edit Dialog */}
{showMissionDialog && profile && (
<MissionEditDialog
mission={profile.mission || ""}
onClose={() => setShowMissionDialog(false)}
onSaved={async () => {
await loadData();
setShowMissionDialog(false);
}}
/>
)}
</div>
);
}
// ============= DISPOSITION EDIT DIALOG =============
function DispositionEditDialog({
disposition,
onClose,
onSaved,
}: {
disposition: DispositionTraits;
onClose: () => void;
onSaved: () => void;
}) {
const { currentBank } = useBank();
const [saving, setSaving] = useState(false);
const [editDisposition, setEditDisposition] = useState<DispositionTraits>(disposition);
const handleSave = async () => {
if (!currentBank) return;
setSaving(true);
try {
await client.updateBankProfile(currentBank, {
disposition: editDisposition,
});
onSaved();
} catch (error) {
console.error("Error saving disposition:", error);
alert("Error saving disposition: " + (error as Error).message);
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Edit Disposition Traits</DialogTitle>
<DialogDescription>Traits that shape the reasoning and perspective</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
<div key={trait} className="space-y-2">
<div className="flex justify-between items-center">
<div>
<label className="text-sm font-medium text-foreground">
{TRAIT_LABELS[trait].label}
</label>
<p className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].description}</p>
</div>
<span className="text-sm font-bold text-primary">{editDisposition[trait]}/5</span>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>{TRAIT_LABELS[trait].lowLabel}</span>
<span>{TRAIT_LABELS[trait].highLabel}</span>
</div>
<input
type="range"
min="1"
max="5"
step="1"
value={editDisposition[trait]}
onChange={(e) =>
setEditDisposition((prev) => ({ ...prev, [trait]: parseInt(e.target.value) }))
}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
))}
</div>
<DialogFooter>
<Button onClick={onClose} variant="outline" disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============= MISSION EDIT DIALOG =============
function MissionEditDialog({
mission,
onClose,
onSaved,
}: {
mission: string;
onClose: () => void;
onSaved: () => void;
}) {
const { currentBank } = useBank();
const [saving, setSaving] = useState(false);
const [editMission, setEditMission] = useState(mission);
const handleSave = async () => {
if (!currentBank) return;
setSaving(true);
try {
await client.updateBankProfile(currentBank, {
mission: editMission,
});
onSaved();
} catch (error) {
console.error("Error saving mission:", error);
alert("Error saving mission: " + (error as Error).message);
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Edit Mission</DialogTitle>
<DialogDescription>
Affects how observations, reflect, and mental models are created
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-4">
<Textarea
value={editMission}
onChange={(e) => setEditMission(e.target.value)}
placeholder="e.g., I am a PM for the engineering team. I help coordinate sprints and track project progress..."
rows={8}
className="resize-none"
/>
</div>
<DialogFooter>
<Button onClick={onClose} variant="outline" disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============= DIRECTIVE FORM DIALOG (CREATE/EDIT) =============
function DirectiveFormDialog({

View file

@ -0,0 +1,208 @@
"use client";
import { useState, useEffect } from "react";
import { useBank } from "@/lib/bank-context";
import { useFeatures } from "@/lib/features-context";
import { client } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Database, Link2, FolderOpen, Activity, Clock } from "lucide-react";
interface BankStats {
bank_id: string;
total_nodes: number;
total_links: number;
total_documents: number;
nodes_by_fact_type: {
world?: number;
experience?: number;
opinion?: number;
};
links_by_link_type: {
temporal?: number;
semantic?: number;
entity?: number;
};
pending_operations: number;
failed_operations: number;
last_consolidated_at: string | null;
pending_consolidation: number;
total_mental_models: number;
}
export function BankStatsView() {
const { currentBank } = useBank();
const { features } = useFeatures();
const observationsEnabled = features?.observations ?? false;
const [stats, setStats] = useState<BankStats | null>(null);
const [mentalModelsCount, setMentalModelsCount] = useState(0);
const [directivesCount, setDirectivesCount] = useState(0);
const [loading, setLoading] = useState(false);
const loadData = async () => {
if (!currentBank) return;
setLoading(true);
try {
const [statsData, mentalModelsData, directivesData] = await Promise.all([
client.getBankStats(currentBank),
client.listMentalModels(currentBank),
client.listDirectives(currentBank),
]);
setStats(statsData as BankStats);
setMentalModelsCount(mentalModelsData.items?.length || 0);
setDirectivesCount(directivesData.items?.length || 0);
} catch (error) {
console.error("Error loading bank stats:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (currentBank) {
loadData();
// Refresh stats every 5 seconds
const interval = setInterval(loadData, 5000);
return () => clearInterval(interval);
}
}, [currentBank]);
if (loading && !stats) {
return (
<div className="flex items-center justify-center py-12">
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
</div>
);
}
if (!stats) return null;
return (
<div className="space-y-6">
{/* Stats Overview - Compact cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-600/5 border-blue-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20">
<Database className="w-5 h-5 text-blue-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Memories</p>
<p className="text-2xl font-bold text-foreground">{stats.total_nodes}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-600/5 border-purple-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-500/20">
<Link2 className="w-5 h-5 text-purple-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Links</p>
<p className="text-2xl font-bold text-foreground">{stats.total_links}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-emerald-500/10 to-emerald-600/5 border-emerald-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/20">
<FolderOpen className="w-5 h-5 text-emerald-500" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Documents</p>
<p className="text-2xl font-bold text-foreground">{stats.total_documents}</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`bg-gradient-to-br ${stats.pending_operations > 0 ? "from-amber-500/10 to-amber-600/5 border-amber-500/20" : "from-slate-500/10 to-slate-600/5 border-slate-500/20"}`}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-lg ${stats.pending_operations > 0 ? "bg-amber-500/20" : "bg-slate-500/20"}`}
>
<Activity
className={`w-5 h-5 ${stats.pending_operations > 0 ? "text-amber-500 animate-pulse" : "text-slate-500"}`}
/>
</div>
<div>
<p className="text-xs text-muted-foreground font-medium">Pending</p>
<p className="text-2xl font-bold text-foreground">{stats.pending_operations}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Memory Type Breakdown */}
<div className="grid grid-cols-5 gap-3">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-blue-600 dark:text-blue-400 font-semibold uppercase tracking-wide">
World Facts
</p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{stats.nodes_by_fact_type?.world || 0}
</p>
</div>
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wide">
Experience
</p>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mt-1">
{stats.nodes_by_fact_type?.experience || 0}
</p>
</div>
<div
className={`rounded-xl p-4 text-center ${
observationsEnabled
? "bg-amber-500/10 border border-amber-500/20"
: "bg-muted/50 border border-muted"
}`}
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
>
<p
className={`text-xs font-semibold uppercase tracking-wide ${
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
}`}
>
Observations
{!observationsEnabled && <span className="ml-1 normal-case">(Off)</span>}
</p>
<p
className={`text-2xl font-bold mt-1 ${
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
}`}
>
{observationsEnabled ? stats.total_mental_models || 0 : "—"}
</p>
</div>
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-semibold uppercase tracking-wide">
Mental Models
</p>
<p className="text-2xl font-bold text-cyan-600 dark:text-cyan-400 mt-1">
{mentalModelsCount}
</p>
</div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 text-center">
<p className="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wide">
Directives
</p>
<p className="text-2xl font-bold text-rose-600 dark:text-rose-400 mt-1">
{directivesCount}
</p>
</div>
</div>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { useState } from "react";
import { useBank } from "@/lib/bank-context";
import { useFeatures } from "@/lib/features-context";
import {
Search,
Sparkles,
@ -11,6 +12,7 @@ import {
ChevronLeft,
ChevronRight,
Box,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
import Link from "next/link";
@ -24,6 +26,7 @@ interface SidebarProps {
export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
const { currentBank } = useBank();
const { features } = useFeatures();
const [isCollapsed, setIsCollapsed] = useState(true);
if (!currentBank) {
@ -36,7 +39,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
{ id: "reflect" as NavItem, label: "Reflect", icon: Sparkles },
{ id: "documents" as NavItem, label: "Documents", icon: FileText },
{ id: "entities" as NavItem, label: "Entities", icon: Users },
{ id: "profile" as NavItem, label: "Memory Bank", icon: Box },
{ id: "profile" as NavItem, label: "Bank Configuration", icon: Settings },
];
return (

View file

@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
)
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View file

@ -684,9 +684,48 @@ export class ControlPlaneClient {
observations: boolean;
mcp: boolean;
worker: boolean;
bank_config_api: boolean;
};
}>("/api/version");
}
/**
* Get bank configuration (resolved with hierarchy)
*/
async getBankConfig(bankId: string) {
return this.fetchApi<{
bank_id: string;
config: Record<string, any>;
overrides: Record<string, any>;
}>(`/api/banks/${bankId}/config`);
}
/**
* Update bank configuration overrides
*/
async updateBankConfig(bankId: string, updates: Record<string, any>) {
return this.fetchApi<{
bank_id: string;
config: Record<string, any>;
overrides: Record<string, any>;
}>(`/api/banks/${bankId}/config`, {
method: "PATCH",
body: JSON.stringify({ updates }),
});
}
/**
* Reset bank configuration to defaults
*/
async resetBankConfig(bankId: string) {
return this.fetchApi<{
bank_id: string;
config: Record<string, any>;
overrides: Record<string, any>;
}>(`/api/banks/${bankId}/config`, {
method: "DELETE",
});
}
}
// Export singleton instance

View file

@ -7,6 +7,7 @@ interface Features {
observations: boolean;
mcp: boolean;
worker: boolean;
bank_config_api: boolean;
}
interface FeaturesContextType {
@ -19,6 +20,7 @@ const defaultFeatures: Features = {
observations: false,
mcp: false,
worker: false,
bank_config_api: false,
};
const FeaturesContext = createContext<FeaturesContextType | undefined>(undefined);

View file

@ -657,6 +657,127 @@ The Control Plane is the web UI for managing memory banks.
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
```
### Hierarchical Configuration
Hindsight supports per-bank configuration overrides through a hierarchical system: **Global (env vars) → Tenant → Bank**.
#### Type-Safe Config Access
To prevent accidentally using global defaults when bank-specific overrides exist, Hindsight enforces type-safe config access:
**In Application Code:**
```python
from hindsight_api.config import get_config
# ✅ Access static (infrastructure) fields
config = get_config()
host = config.host # OK - static field
port = config.port # OK - static field
# ❌ Attempting to access bank-configurable fields raises an error
chunk_size = config.retain_chunk_size # ConfigFieldAccessError!
```
**Error Message:**
```
ConfigFieldAccessError: Field 'retain_chunk_size' is bank-configurable and cannot
be accessed from global config. Use ConfigResolver.resolve_full_config(bank_id, context)
to get bank-specific config.
```
**For Bank-Specific Config:**
```python
# Internal code that needs bank-specific settings
from hindsight_api.config_resolver import ConfigResolver
# Resolve full config for a specific bank
config = await config_resolver.resolve_full_config(bank_id, request_context)
chunk_size = config.retain_chunk_size # ✅ Uses bank-specific value
```
This design prevents bugs where global defaults are used instead of bank overrides, making it impossible to make this mistake at compile/development time.
#### Security Model
Configuration fields are categorized for security:
1. **Configurable Fields** - Safe behavioral settings that can be customized per-bank:
- Retention: `retain_chunk_size`, `retain_extraction_mode`, `retain_custom_instructions`
- Consolidation: `enable_observations`
2. **Credential Fields** - NEVER exposed or configurable via API:
- API keys: `*_api_key` (all LLM API keys)
- Infrastructure: `*_base_url` (all base URLs)
3. **Static Fields** - Server-level only, cannot be overridden:
- Infrastructure: `database_url`, `port`, `host`, `worker_count`
- Provider/Model selection: `llm_provider`, `llm_model` (requires presets - not yet implemented)
- Performance tuning: `llm_max_concurrent`, `llm_timeout`, retrieval settings, optimization flags
#### Enabling the API
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `false` |
**Important:** The bank config API is **disabled by default** for security. Enable it explicitly:
```bash
export HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true
```
#### API Endpoints
- `GET /v1/default/banks/{bank_id}/config` - View resolved config (filtered by permissions)
- `PATCH /v1/default/banks/{bank_id}/config` - Update bank overrides (only allowed fields)
- `DELETE /v1/default/banks/{bank_id}/config` - Reset to defaults
#### Permission System
Tenant extensions can control which fields banks are allowed to modify via `get_allowed_config_fields()`:
```python
class CustomTenantExtension(TenantExtension):
async def get_allowed_config_fields(self, context, bank_id):
# Option 1: Allow all configurable fields
return None
# Option 2: Allow specific fields only
return {"retain_chunk_size", "retain_custom_instructions"}
# Option 3: Read-only (no modifications)
return set()
```
#### Examples
```bash
# Update retention settings for a bank
curl -X PATCH http://localhost:8888/v1/default/banks/my-bank/config \
-H "Content-Type: application/json" \
-d '{
"updates": {
"retain_chunk_size": 4000,
"retain_extraction_mode": "custom",
"retain_custom_instructions": "Focus on technical details and implementation specifics"
}
}'
# Note: retain_extraction_mode must be "custom" to use retain_custom_instructions
# View resolved config (respects permissions)
curl http://localhost:8888/v1/default/banks/my-bank/config
# Reset to defaults
curl -X DELETE http://localhost:8888/v1/default/banks/my-bank/config
```
**Security Notes:**
- Credentials (API keys, base URLs) are never returned in responses
- Only configurable fields can be modified
- Responses are filtered by tenant permissions
- Attempting to set credentials returns 400 error
### Reverse Proxy / Subpath Deployment
To deploy Hindsight under a subpath (e.g., `example.com/hindsight/`):
@ -721,7 +842,6 @@ See `docker/compose-examples/` directory for:
- Docker Compose setups (`docker-compose.yml`, `reverse-proxy-only.yml`)
- Traefik and other reverse proxy examples
- Full deployment documentation
---
## Example .env File

View file

@ -2749,6 +2749,189 @@
}
}
},
"/v1/default/banks/{bank_id}/config": {
"get": {
"tags": [
"Banks"
],
"summary": "Get bank configuration",
"description": "Get fully resolved configuration for a bank including all hierarchical overrides (global \u2192 tenant \u2192 bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.",
"operationId": "get_bank_config",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BankConfigResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"patch": {
"tags": [
"Banks"
],
"summary": "Update bank configuration",
"description": "Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).",
"operationId": "update_bank_config",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BankConfigUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BankConfigResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"Banks"
],
"summary": "Reset bank configuration",
"description": "Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.",
"operationId": "reset_bank_config",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BankConfigResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/consolidate": {
"post": {
"tags": [
@ -3042,6 +3225,70 @@
"mission": "I was born in Texas. I am a software engineer with 10 years of experience."
}
},
"BankConfigResponse": {
"properties": {
"bank_id": {
"type": "string",
"title": "Bank Id",
"description": "Bank identifier"
},
"config": {
"additionalProperties": true,
"type": "object",
"title": "Config",
"description": "Fully resolved configuration with all hierarchical overrides applied (Python field names)"
},
"overrides": {
"additionalProperties": true,
"type": "object",
"title": "Overrides",
"description": "Bank-specific configuration overrides only (Python field names)"
}
},
"type": "object",
"required": [
"bank_id",
"config",
"overrides"
],
"title": "BankConfigResponse",
"description": "Response model for bank configuration.",
"example": {
"bank_id": "my-bank",
"config": {
"llm_model": "gpt-4",
"llm_provider": "openai",
"retain_extraction_mode": "verbose"
},
"overrides": {
"llm_model": "gpt-4",
"retain_extraction_mode": "verbose"
}
}
},
"BankConfigUpdate": {
"properties": {
"updates": {
"additionalProperties": true,
"type": "object",
"title": "Updates",
"description": "Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank."
}
},
"type": "object",
"required": [
"updates"
],
"title": "BankConfigUpdate",
"description": "Request model for updating bank configuration.",
"example": {
"updates": {
"llm_model": "claude-sonnet-4-5",
"retain_custom_instructions": "Extract technical details carefully",
"retain_extraction_mode": "verbose"
}
}
},
"BankListItem": {
"properties": {
"bank_id": {
@ -4243,13 +4490,19 @@
"type": "boolean",
"title": "Worker",
"description": "Whether the background worker is enabled"
},
"bank_config_api": {
"type": "boolean",
"title": "Bank Config Api",
"description": "Whether per-bank configuration API is enabled"
}
},
"type": "object",
"required": [
"observations",
"mcp",
"worker"
"worker",
"bank_config_api"
],
"title": "FeaturesInfo",
"description": "Feature flags indicating which capabilities are enabled."
@ -6259,6 +6512,7 @@
"example": {
"api_version": "0.4.0",
"features": {
"bank_config_api": false,
"mcp": true,
"observations": false,
"worker": true