Compare commits

..
1 Commits
Author SHA1 Message Date
Nicolò Boschi c949191953 feat: add real-time timing breakdown logging for consolidation
- Log timing breakdown after each batch (every 50 memories by default)
- Log timing breakdown in progress logs (every 10 memories)
- Shows recall, llm, embedding, db_write times incrementally
- Includes avg time per memory for quick diagnosis
- Helps diagnose performance issues in production without waiting for job completion

Example output (every 10 memories):
[CONSOLIDATION] bank=xyz progress: 10/39303 memories processed | recall=2.09s, llm=11.03s, embedding=0.48s, db_write=0.02s

Example output (per batch):
[CONSOLIDATION] bank=xyz batch 1/50 memories: recall=7.3s, llm=57.5s, embedding=2.0s, db_write=0.09s | avg=1.3s/memory
2026-01-29 17:37:01 +01:00
176 changed files with 1432 additions and 5403 deletions
+1 -8
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=o3-mini
@@ -13,13 +13,6 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Example: Google Vertex AI configuration
# HINDSIGHT_API_LLM_PROVIDER=vertexai
# HINDSIGHT_API_LLM_MODEL=google/gemini-2.0-flash-001
# HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-gcp-project-id
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
+1 -58
View File
@@ -139,55 +139,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclawd-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclawd
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclawd
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclawd
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclawd
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: openclawd-integration
path: hindsight-integrations/openclawd/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -415,7 +366,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclawd-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -438,12 +389,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClawd Integration
uses: actions/download-artifact@v4
with:
name: openclawd-integration
path: ./artifacts/openclawd-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
@@ -485,8 +430,6 @@ jobs:
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClawd Integration
cp artifacts/openclawd-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
-23
View File
@@ -82,29 +82,6 @@ jobs:
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-openclawd-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclawd
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/openclawd
run: npm test
- name: Build
working-directory: ./hindsight-integrations/openclawd
run: npm run build
build-control-plane:
runs-on: ubuntu-latest
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.2
appVersion: "0.4.2"
version: 0.4.1
appVersion: "0.4.1"
keywords:
- ai
- memory
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.2"
__version__ = "0.4.1"
+18 -5
View File
@@ -92,7 +92,8 @@ class RecallRequest(BaseModel):
query: str
types: list[str] | None = Field(
default=None,
description="List of fact types to recall: 'world', 'experience', 'observation'. Defaults to world and experience if not specified.",
description="List of fact types to recall: 'world', 'experience', 'observation'. Defaults to world and experience if not specified. "
"Note: 'opinion' is accepted but ignored (opinions are excluded from recall).",
)
budget: Budget = Budget.MID
max_tokens: int = 4096
@@ -503,6 +504,13 @@ class ReflectRequest(BaseModel):
)
class OpinionItem(BaseModel):
"""Model for an opinion with confidence score."""
text: str
confidence: float
class ReflectFact(BaseModel):
"""A fact used in think response."""
@@ -521,7 +529,7 @@ class ReflectFact(BaseModel):
id: str | None = None
text: str
type: str | None = None # fact type: world, experience, observation
type: str | None = None # fact type: world, experience, opinion
context: str | None = None
occurred_start: str | None = None
occurred_end: str | None = None
@@ -1699,7 +1707,9 @@ def _register_routes(app: FastAPI):
description="Recall memory using semantic similarity and spreading activation.\n\n"
"The type parameter is optional and must be one of:\n"
"- `world`: General knowledge about people, places, events, and things that happen\n"
"- `experience`: Memories about experience, conversations, actions taken, and tasks performed",
"- `experience`: Memories about experience, conversations, actions taken, and tasks performed\n"
"- `opinion`: The bank's formed beliefs, perspectives, and viewpoints\n\n"
"Set `include_entities=true` to get entity observations alongside recall results.",
operation_id="recall_memories",
tags=["Memory"],
)
@@ -1713,8 +1723,10 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
try:
# Default to world and experience if not specified (exclude observation)
# Default to world and experience if not specified (exclude observation and opinion)
# Filter out 'opinion' even if requested - opinions are excluded from recall
fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
fact_types = [ft for ft in fact_types if ft != "opinion"]
# Parse query_timestamp if provided
question_date = None
@@ -1846,7 +1858,8 @@ def _register_routes(app: FastAPI):
"2. Retrieves world facts relevant to the query\n"
"3. Retrieves existing opinions (bank's perspectives)\n"
"4. Uses LLM to formulate a contextual answer\n"
"5. Returns plain text answer and the facts used",
"5. Extracts and stores any new opinions formed\n"
"6. Returns plain text answer, the facts used, and new opinions",
operation_id="reflect",
tags=["Memory"],
)
+5 -45
View File
@@ -29,26 +29,15 @@ logger = logging.getLogger(__name__)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# MCP authentication token (optional - if set, Bearer token auth is required)
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
# Context variable to hold the current bank_id
_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None)
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
return _current_bank_id.get()
def get_current_api_key() -> str | None:
"""Get the current API key from context."""
return _current_api_key.get()
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -65,7 +54,6 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
tools=None, # All tools
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
@@ -77,11 +65,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
class MCPMiddleware:
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
Authentication:
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
Authorization header with Bearer token or direct token matching the configured value.
"""ASGI middleware that extracts bank_id from header or path and sets context.
Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
@@ -90,7 +74,7 @@ class MCPMiddleware:
For Claude Code, configure with:
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
--header "X-Bank-Id: my-bank"
"""
def __init__(self, app, memory: MemoryEngine):
@@ -114,22 +98,6 @@ class MCPMiddleware:
await self.mcp_app(scope, receive, send)
return
# Extract auth token from header (for tenant auth propagation)
auth_header = self._get_header(scope, "Authorization")
auth_token: str | None = None
if auth_header:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Authenticate if MCP_AUTH_TOKEN is configured
if MCP_AUTH_TOKEN:
if not auth_token:
await self._send_error(send, 401, "Authorization header required")
return
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
path = scope.get("path", "")
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
@@ -164,10 +132,8 @@ class MCPMiddleware:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Set bank_id and api_key context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Set bank_id context
token = _current_bank_id.set(bank_id)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
@@ -186,9 +152,7 @@ class MCPMiddleware:
await self.mcp_app(new_scope, receive, send_wrapper)
finally:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
_current_bank_id.reset(token)
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
@@ -212,10 +176,6 @@ def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
Authentication:
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
If not set, MCP endpoint is open (for local development).
Bank ID can be provided via:
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
2. URL path: /mcp/{bank_id}/
+7 -105
View File
@@ -26,9 +26,6 @@ ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
ENV_LLM_MAX_CONCURRENT = "HINDSIGHT_API_LLM_MAX_CONCURRENT"
ENV_LLM_MAX_RETRIES = "HINDSIGHT_API_LLM_MAX_RETRIES"
ENV_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_LLM_INITIAL_BACKOFF"
ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
@@ -37,31 +34,16 @@ ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
ENV_RETAIN_LLM_MODEL = "HINDSIGHT_API_RETAIN_LLM_MODEL"
ENV_RETAIN_LLM_BASE_URL = "HINDSIGHT_API_RETAIN_LLM_BASE_URL"
ENV_RETAIN_LLM_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT"
ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
ENV_REFLECT_LLM_MAX_CONCURRENT = "HINDSIGHT_API_REFLECT_LLM_MAX_CONCURRENT"
ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
ENV_CONSOLIDATION_LLM_MODEL = "HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"
ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT"
ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES"
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
@@ -108,17 +90,13 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
@@ -155,16 +133,8 @@ DEFAULT_DATABASE_SCHEMA = "public"
DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
# Vertex AI defaults
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
@@ -209,6 +179,7 @@ DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
@@ -315,46 +286,23 @@ class HindsightConfig:
llm_model: str
llm_base_url: str | None
llm_max_concurrent: int
llm_max_retries: int
llm_initial_backoff: float
llm_max_backoff: float
llm_timeout: float
# Vertex AI configuration
llm_vertexai_project_id: str | None
llm_vertexai_region: str
llm_vertexai_service_account_key: str | None
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
retain_llm_model: str | None
retain_llm_base_url: str | None
retain_llm_max_concurrent: int | None
retain_llm_max_retries: int | None
retain_llm_initial_backoff: float | None
retain_llm_max_backoff: float | None
retain_llm_timeout: float | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
reflect_llm_model: str | None
reflect_llm_base_url: str | None
reflect_llm_max_concurrent: int | None
reflect_llm_max_retries: int | None
reflect_llm_initial_backoff: float | None
reflect_llm_max_backoff: float | None
reflect_llm_timeout: float | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
consolidation_llm_model: str | None
consolidation_llm_base_url: str | None
consolidation_llm_max_concurrent: int | None
consolidation_llm_max_retries: int | None
consolidation_llm_initial_backoff: float | None
consolidation_llm_max_backoff: float | None
consolidation_llm_timeout: float | None
# Embeddings
embeddings_provider: str
@@ -395,6 +343,7 @@ class HindsightConfig:
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_custom_instructions: str | None
retain_observations_async: bool
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
@@ -438,71 +387,20 @@ class HindsightConfig:
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
llm_max_concurrent=int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT))),
llm_max_retries=int(os.getenv(ENV_LLM_MAX_RETRIES, str(DEFAULT_LLM_MAX_RETRIES))),
llm_initial_backoff=float(os.getenv(ENV_LLM_INITIAL_BACKOFF, str(DEFAULT_LLM_INITIAL_BACKOFF))),
llm_max_backoff=float(os.getenv(ENV_LLM_MAX_BACKOFF, str(DEFAULT_LLM_MAX_BACKOFF))),
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
retain_llm_model=os.getenv(ENV_RETAIN_LLM_MODEL) or None,
retain_llm_base_url=os.getenv(ENV_RETAIN_LLM_BASE_URL) or None,
retain_llm_max_concurrent=int(os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT))
if os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT)
else None,
retain_llm_max_retries=int(os.getenv(ENV_RETAIN_LLM_MAX_RETRIES))
if os.getenv(ENV_RETAIN_LLM_MAX_RETRIES)
else None,
retain_llm_initial_backoff=float(os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF)
else None,
retain_llm_max_backoff=float(os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF))
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
else None,
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
reflect_llm_max_concurrent=int(os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT))
if os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT)
else None,
reflect_llm_max_retries=int(os.getenv(ENV_REFLECT_LLM_MAX_RETRIES))
if os.getenv(ENV_REFLECT_LLM_MAX_RETRIES)
else None,
reflect_llm_initial_backoff=float(os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF)
else None,
reflect_llm_max_backoff=float(os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF))
if os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF)
else None,
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
else None,
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL) or None,
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or None,
consolidation_llm_max_concurrent=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT)
else None,
consolidation_llm_max_retries=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES)
else None,
consolidation_llm_initial_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF))
if os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF)
else None,
consolidation_llm_max_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF))
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF)
else None,
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -562,6 +460,10 @@ class HindsightConfig:
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
),
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_observations_async=os.getenv(
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
consolidation_batch_size=int(
+1 -4
View File
@@ -52,10 +52,7 @@ class IdleTimeoutMiddleware:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
# Send SIGTERM to ourselves to trigger graceful shutdown
import signal
os.kill(os.getpid(), signal.SIGTERM)
os._exit(0)
class DaemonLock:
@@ -865,14 +865,7 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
)
# Parse JSON response - should be an array
if isinstance(result, str):
# Strip markdown code fences (some models wrap JSON in ```json ... ```)
clean = result.strip()
if clean.startswith("```"):
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
if clean.endswith("```"):
clean = clean[:-3]
clean = clean.strip()
result = json.loads(clean)
result = json.loads(result)
# Ensure result is a list
if isinstance(result, list):
return result
@@ -178,16 +178,108 @@ class LocalSTCrossEncoder(CrossEncoderModel):
else:
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the cross-encoder model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing reranker model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model
try:
from sentence_transformers import CrossEncoder
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTCrossEncoder. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
if self.force_cpu:
device = "cpu"
else:
# Wrap in try-except to gracefully handle any device detection issues
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS during reinit, falling back to CPU: {e}")
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Reranker: local provider reinitialized successfully")
def _predict_with_recovery(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Predict with automatic recovery from XPC errors.
This runs synchronously in the thread pool.
"""
max_retries = 1
for attempt in range(max_retries + 1):
try:
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in reranker (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Reranker reinitialized successfully, retrying prediction")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize reranker: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
Uses a dedicated thread pool with limited workers to prevent CPU thrashing.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
pairs: List of (query, document) tuples to score
@@ -202,7 +294,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
LocalSTCrossEncoder._executor,
self._predict_sync,
self._predict_with_recovery,
pairs,
)
@@ -614,7 +706,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return
try:
from flashrank import Ranker
from flashrank import Ranker # type: ignore[import-untyped]
except ImportError:
raise ImportError("flashrank is required for FlashRankCrossEncoder. Install it with: pip install flashrank")
@@ -641,7 +733,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
from flashrank import RerankRequest
from flashrank import RerankRequest # type: ignore[import-untyped]
if not pairs:
return []
@@ -166,10 +166,82 @@ class LocalSTEmbeddings(Embeddings):
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the embedding model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing embedding model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model (inline version of initialize() but synchronous)
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTEmbeddings. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
if self.force_cpu:
device = "cpu"
else:
# Wrap in try-except to gracefully handle any device detection issues
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS during reinit, falling back to CPU: {e}")
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Embeddings: local provider reinitialized successfully")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for a list of texts.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
texts: List of text strings to encode
@@ -179,8 +251,26 @@ class LocalSTEmbeddings(Embeddings):
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
# Try encoding with automatic recovery from XPC errors
max_retries = 1
for attempt in range(max_retries + 1):
try:
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in embedding generation (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Model reinitialized successfully, retrying embedding generation")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize model: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
class RemoteTEIEmbeddings(Embeddings):
@@ -545,7 +635,7 @@ class CohereEmbeddings(Embeddings):
model=self.model,
input_type=self.input_type,
)
if response.embeddings and isinstance(response.embeddings, list):
if response.embeddings:
self._dimension = len(response.embeddings[0])
logger.info(f"Embeddings: Cohere provider initialized (model: {self.model}, dim: {self._dimension})")
@@ -442,6 +442,49 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def get_entity_observations(
self,
bank_id: str,
entity_id: str,
*,
limit: int = 10,
request_context: "RequestContext",
) -> list[Any]:
"""
Get observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
limit: Maximum observations.
request_context: Request context for authentication.
Returns:
List of EntityObservation objects.
"""
...
@abstractmethod
async def regenerate_entity_observations(
self,
bank_id: str,
entity_id: str,
entity_name: str,
*,
request_context: "RequestContext",
) -> None:
"""
Regenerate observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
entity_name: The entity's canonical name.
request_context: Request context for authentication.
"""
...
# =========================================================================
# Statistics & Operations
# =========================================================================
@@ -16,15 +16,6 @@ from google.genai import errors as genai_errors
from google.genai import types as genai_types
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
# Vertex AI imports (conditional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
except ImportError:
VERTEXAI_AVAILABLE = False
from ..config import (
DEFAULT_LLM_MAX_CONCURRENT,
DEFAULT_LLM_TIMEOUT,
@@ -97,7 +88,7 @@ class LLMProvider:
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
# Validate provider
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio", "vertexai", "mock"]
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio", "mock"]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -114,51 +105,8 @@ class LLMProvider:
elif self.provider == "lmstudio":
self.base_url = "http://localhost:1234/v1"
# Vertex AI config — stored for client creation below
self._vertexai_project_id: str | None = None
self._vertexai_region: str | None = None
self._vertexai_credentials: Any = None
if self.provider == "vertexai":
from ..config import get_config
config = get_config()
self._vertexai_project_id = config.llm_vertexai_project_id
if not self._vertexai_project_id:
raise ValueError(
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
"Set it to your GCP project ID."
)
self._vertexai_region = config.llm_vertexai_region or "us-central1"
service_account_key = config.llm_vertexai_service_account_key
# Load explicit service account credentials if provided
if service_account_key:
if not VERTEXAI_AVAILABLE:
raise ValueError(
"Vertex AI service account auth requires 'google-auth' package. "
"Install with: pip install google-auth"
)
self._vertexai_credentials = service_account.Credentials.from_service_account_file(
service_account_key,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
logger.info(f"Vertex AI: Using service account key: {service_account_key}")
# Strip google/ prefix from model name — native SDK uses bare names
# e.g. "google/gemini-2.0-flash-lite-001" -> "gemini-2.0-flash-lite-001"
if self.model.startswith("google/"):
self.model = self.model[len("google/") :]
logger.info(
f"Vertex AI: project={self._vertexai_project_id}, region={self._vertexai_region}, "
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
# Validate API key (not needed for ollama, lmstudio, vertexai, or mock)
if self.provider not in ("ollama", "lmstudio", "vertexai", "mock") and not self.api_key:
# Validate API key (not needed for ollama, lmstudio, or mock)
if self.provider not in ("ollama", "lmstudio", "mock") and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
# Get timeout config (set HINDSIGHT_API_LLM_TIMEOUT for local LLMs that need longer timeouts)
@@ -184,17 +132,6 @@ class LLMProvider:
if self.timeout:
anthropic_kwargs["timeout"] = self.timeout
self._anthropic_client = AsyncAnthropic(**anthropic_kwargs)
elif self.provider == "vertexai":
# Native genai SDK with Vertex AI — handles ADC automatically,
# or uses explicit service account credentials if provided
client_kwargs = {
"vertexai": True,
"project": self._vertexai_project_id,
"location": self._vertexai_region,
}
if self._vertexai_credentials is not None:
client_kwargs["credentials"] = self._vertexai_credentials
self._gemini_client = genai.Client(**client_kwargs)
elif self.provider in ("ollama", "lmstudio"):
# Use dummy key if not provided for local
api_key = self.api_key or "local"
@@ -286,8 +223,8 @@ class LLMProvider:
return_usage,
)
# Handle Gemini and Vertex AI providers (both use native genai SDK)
if self.provider in ("gemini", "vertexai"):
# Handle Gemini provider separately
if self.provider == "gemini":
return await self._call_gemini(
messages,
response_format,
@@ -405,13 +342,11 @@ class LLMProvider:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] += schema_msg
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
@@ -651,8 +586,8 @@ class LLMProvider:
messages, tools, max_completion_tokens, max_retries, initial_backoff, max_backoff, start_time, scope
)
# Handle Gemini and Vertex AI (convert to Gemini tool format)
if self.provider in ("gemini", "vertexai"):
# Handle Gemini (convert to Gemini tool format)
if self.provider == "gemini":
return await self._call_with_tools_gemini(
messages, tools, max_retries, initial_backoff, max_backoff, start_time, scope
)
@@ -982,20 +917,18 @@ class LLMProvider:
tool_calls: list[LLMToolCall] = []
if response.candidates and response.candidates[0].content:
parts = response.candidates[0].content.parts
if parts:
for part in parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
for part in response.candidates[0].content.parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
)
finish_reason = "tool_calls" if tool_calls else "stop"
@@ -1571,10 +1504,6 @@ class LLMProvider:
"""Clear the recorded mock calls."""
self._mock_calls = []
async def cleanup(self) -> None:
"""Clean up resources."""
pass
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
@@ -504,11 +504,12 @@ class MemoryEngine(MemoryEngineInterface):
if request_context is None:
raise AuthenticationError("RequestContext is required when tenant extension is configured")
# For internal/background operations (e.g., worker tasks), skip extension authentication.
# The task was already authenticated at submission time, and execute_task sets _current_schema
# from the task's _schema field. For public schema tasks, _current_schema keeps its default "public".
# For internal/background operations (e.g., worker tasks), skip extension authentication
# if the schema has already been set by execute_task via the _schema field.
if request_context.internal:
return _current_schema.get()
current = _current_schema.get()
if current and current != "public":
return current
# Let AuthenticationError propagate - HTTP layer will convert to 401
tenant_context = await self._tenant_extension.authenticate(request_context)
@@ -788,7 +789,7 @@ class MemoryEngine(MemoryEngineInterface):
kwargs = {"name": self._pg0_instance_name}
if self._pg0_port is not None:
kwargs["port"] = self._pg0_port
pg0 = EmbeddedPostgres(**kwargs)
pg0 = EmbeddedPostgres(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
# Check if pg0 is already running before we start it
was_already_running = await pg0.is_running()
self.db_url = await pg0.ensure_running()
@@ -888,23 +889,6 @@ class MemoryEngine(MemoryEngineInterface):
# Use configured database schema for migrations (defaults to "public")
run_migrations(self.db_url, schema=get_config().database_schema)
# Migrate all existing tenant schemas (if multi-tenant)
if self._tenant_extension is not None:
try:
tenants = await self._tenant_extension.list_tenants()
if tenants:
logger.info(f"Running migrations on {len(tenants)} tenant schemas...")
for tenant in tenants:
schema = tenant.schema
if schema and schema != "public":
try:
run_migrations(self.db_url, schema=schema)
except Exception as e:
logger.warning(f"Failed to migrate tenant schema {schema}: {e}")
logger.info("Tenant schema migrations completed")
except Exception as e:
logger.warning(f"Failed to run tenant schema migrations: {e}")
# Ensure embedding column dimension matches the model's dimension
# This is done after migrations and after embeddings.initialize()
ensure_embedding_dimension(self.db_url, self.embeddings.dimension, schema=get_config().database_schema)
@@ -1191,15 +1175,15 @@ class MemoryEngine(MemoryEngineInterface):
context: Context about when/why this memory was formed
event_date: When the event occurred (defaults to now)
document_id: Optional document ID for tracking (always upserts if document already exists)
fact_type_override: Override fact type ('world', 'experience')
confidence_score: Confidence score (0.0 to 1.0)
fact_type_override: Override fact type ('world', 'experience', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0)
request_context: Request context for authentication.
Returns:
List of created unit IDs
"""
# Build content dict
content_dict: RetainContentDict = {"content": content, "context": context}
content_dict: RetainContentDict = {"content": content, "context": context} # type: ignore[typeddict-item] - building incrementally
if event_date:
content_dict["event_date"] = event_date
if document_id:
@@ -1247,8 +1231,8 @@ class MemoryEngine(MemoryEngineInterface):
- "document_id" (optional): Document ID for this specific content item
document_id: **DEPRECATED** - Use "document_id" key in each content dict instead.
Applies the same document_id to ALL content items that don't specify their own.
fact_type_override: Override fact type for all facts ('world', 'experience')
confidence_score: Confidence score (0.0 to 1.0)
fact_type_override: Override fact type for all facts ('world', 'experience', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0)
return_usage: If True, returns tuple of (unit_ids, TokenUsage). Default False for backward compatibility.
Returns:
@@ -1570,16 +1554,16 @@ class MemoryEngine(MemoryEngineInterface):
if fact_type is None:
fact_type = list(VALID_RECALL_FACT_TYPES)
# Filter out 'opinion' early (deprecated, silently ignore)
fact_type = [ft for ft in fact_type if ft != "opinion"]
# Validate fact types
# Validate fact types early
invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES
if invalid_types:
raise ValueError(
f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}"
)
# Filter out 'opinion' - opinions are no longer returned from recall
fact_type = [ft for ft in fact_type if ft != "opinion"]
if not fact_type:
# All requested types were opinions - return empty result
return RecallResultModel(results=[], entities={}, chunks={})
@@ -2235,15 +2219,44 @@ class MemoryEngine(MemoryEngineInterface):
)
top_results_dicts.append(result_dict)
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
if include_entities and top_scored:
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
if unit_ids:
async with acquire_with_retry(pool) as entity_conn:
entity_rows = await entity_conn.fetch(
f"""
SELECT ue.unit_id, e.id as entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
unit_ids,
)
for row in entity_rows:
unit_id = str(row["unit_id"])
if unit_id not in fact_entity_map:
fact_entity_map[unit_id] = []
fact_entity_map[unit_id].append(
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
)
# Convert results to MemoryFact objects
memory_facts = []
for result_dict in top_results_dicts:
result_id = str(result_dict.get("id"))
# Get entity names for this fact
entity_names = None
if include_entities and result_id in fact_entity_map:
entity_names = [e["canonical_name"] for e in fact_entity_map[result_id]]
memory_facts.append(
MemoryFact(
id=str(result_dict.get("id")),
id=result_id,
text=result_dict.get("text"),
fact_type=result_dict.get("fact_type", "world"),
entities=None, # Entity observations removed
entities=entity_names,
context=result_dict.get("context"),
occurred_start=result_dict.get("occurred_start"),
occurred_end=result_dict.get("occurred_end"),
@@ -2254,12 +2267,38 @@ class MemoryEngine(MemoryEngineInterface):
)
)
# Entity observations removed - always set to None
# Fetch entity observations if requested
entities_dict = None
total_entity_tokens = 0
total_chunk_tokens = 0
if include_entities and fact_entity_map:
# Collect unique entities in order of fact relevance (preserving order from top_scored)
# Use a list to maintain order, but track seen entities to avoid duplicates
entities_ordered = [] # list of (entity_id, entity_name) tuples
seen_entity_ids = set()
# Iterate through facts in relevance order
for sr in top_scored:
unit_id = sr.id
if unit_id in fact_entity_map:
for entity in fact_entity_map[unit_id]:
entity_id = entity["entity_id"]
entity_name = entity["canonical_name"]
if entity_id not in seen_entity_ids:
entities_ordered.append((entity_id, entity_name))
seen_entity_ids.add(entity_id)
# Return entities with empty observations (summaries now live in mental models)
entities_dict = {}
for entity_id, entity_name in entities_ordered:
entities_dict[entity_name] = EntityState(
entity_id=entity_id,
canonical_name=entity_name,
observations=[], # Mental models provide this now
)
# Fetch chunks if requested
chunks_dict = None
total_chunk_tokens = 0
if include_chunks and top_scored:
from .response_models import ChunkInfo
@@ -2328,6 +2367,7 @@ class MemoryEngine(MemoryEngineInterface):
# Log final recall stats
total_time = time.time() - recall_start
num_chunks = len(chunks_dict) if chunks_dict else 0
num_entities = len(entities_dict) if entities_dict else 0
# Include wait times in log if significant
wait_parts = []
if semaphore_wait > 0.01:
@@ -2336,7 +2376,7 @@ class MemoryEngine(MemoryEngineInterface):
wait_parts.append(f"conn={max_conn_wait:.3f}s")
wait_info = f" | waits: {', '.join(wait_parts)}" if wait_parts else ""
log_buffer.append(
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
)
if not quiet:
logger.info("\n" + "\n".join(log_buffer))
@@ -3510,6 +3550,7 @@ class MemoryEngine(MemoryEngineInterface):
ReflectResult containing:
- text: Plain text answer
- based_on: Empty dict (agent retrieves facts dynamically)
- new_opinions: Empty list
- structured_output: None (not yet supported for agentic reflect)
"""
# Use cached LLM config
@@ -3834,6 +3875,7 @@ class MemoryEngine(MemoryEngineInterface):
result = ReflectResult(
text=agent_result.text,
based_on=based_on,
new_opinions=[], # Learnings stored as mental models
structured_output=agent_result.structured_output,
usage=usage,
tool_trace=tool_trace_result,
@@ -3862,6 +3904,32 @@ class MemoryEngine(MemoryEngineInterface):
return result
async def get_entity_observations(
self,
bank_id: str,
entity_id: str,
*,
limit: int = 10,
request_context: "RequestContext",
) -> list[Any]:
"""
Get observations for an entity.
NOTE: Entity observations/summaries have been moved to mental models.
This method returns an empty list. Use mental models for entity summaries.
Args:
bank_id: bank IDentifier
entity_id: Entity UUID to get observations for
limit: Ignored (kept for backwards compatibility)
request_context: Request context for authentication.
Returns:
Empty list (observations now in mental models)
"""
await self._authenticate_tenant(request_context)
return []
async def list_entities(
self,
bank_id: str,
@@ -4048,6 +4116,36 @@ class MemoryEngine(MemoryEngineInterface):
await self._authenticate_tenant(request_context)
return EntityState(entity_id=entity_id, canonical_name=entity_name, observations=[])
async def regenerate_entity_observations(
self,
bank_id: str,
entity_id: str,
entity_name: str,
*,
version: str | None = None,
conn=None,
request_context: "RequestContext",
) -> list[str]:
"""
Regenerate observations for an entity.
NOTE: Entity observations/summaries have been moved to mental models.
This method is now a no-op and returns an empty list.
Args:
bank_id: bank IDentifier
entity_id: Entity UUID
entity_name: Canonical name of the entity
version: Entity's last_seen timestamp when task was created (for deduplication)
conn: Optional database connection (ignored)
request_context: Request context for authentication.
Returns:
Empty list (observations now in mental models)
"""
await self._authenticate_tenant(request_context)
return []
# =========================================================================
# Statistics & Operations (for HTTP API layer)
# =========================================================================
@@ -4158,6 +4256,9 @@ class MemoryEngine(MemoryEngineInterface):
if not entity_row:
return None
# Get observations for the entity
observations = await self.get_entity_observations(bank_id, entity_id, limit=20, request_context=request_context)
return {
"id": str(entity_row["id"]),
"canonical_name": entity_row["canonical_name"],
@@ -4165,7 +4266,7 @@ class MemoryEngine(MemoryEngineInterface):
"first_seen": entity_row["first_seen"].isoformat() if entity_row["first_seen"] else None,
"last_seen": entity_row["last_seen"].isoformat() if entity_row["last_seen"] else None,
"metadata": entity_row["metadata"] or {},
"observations": [],
"observations": observations,
}
def _parse_observations(self, observations_raw: list):
@@ -263,6 +263,7 @@ class ReflectResult(BaseModel):
}
],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
"usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
}
@@ -271,8 +272,9 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, Any] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, mental_models, directives)"
description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental_models, directives)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
default=None,
description="Structured output parsed according to the provided response schema. Only present when response_schema was provided.",
@@ -295,6 +297,24 @@ class ReflectResult(BaseModel):
)
class Opinion(BaseModel):
"""
An opinion with confidence score.
Opinions represent the bank's formed perspectives on topics,
with a confidence level indicating strength of belief.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "Machine learning has great potential in healthcare", "confidence": 0.85}
}
)
text: str = Field(description="The opinion text")
confidence: float = Field(description="Confidence score between 0.0 and 1.0")
class EntityObservation(BaseModel):
"""
An observation about an entity.
@@ -693,6 +693,7 @@ async def _extract_facts_from_chunk(
context: str,
llm_config: "LLMConfig",
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
@@ -706,9 +707,17 @@ async def _extract_facts_from_chunk(
logger = logging.getLogger(__name__)
# Determine which fact types to extract
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
# Determine which fact types to extract based on the flag
# Note: We use "assistant" in the prompt but convert to "bank" for storage
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
if extract_opinions:
# Opinion extraction uses a separate prompt (not this one)
fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
else:
fact_types_instruction = (
"Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
)
# Check config for extraction mode and causal link extraction
config = get_config()
@@ -761,6 +770,7 @@ async def _extract_facts_from_chunk(
# Format event_date with day of week for better temporal reasoning
event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024"
user_message = f"""Extract facts from the following text chunk.
{memory_bank_context}
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_formatted} ({event_date.isoformat()})
@@ -772,28 +782,12 @@ Text:
usage = TokenUsage() # Track cumulative usage across retries
for attempt in range(max_retries):
try:
# Use retain-specific overrides if set, otherwise fall back to global LLM config
max_retries = (
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
)
initial_backoff = (
config.retain_llm_initial_backoff
if config.retain_llm_initial_backoff is not None
else config.llm_initial_backoff
)
max_backoff = (
config.retain_llm_max_backoff if config.retain_llm_max_backoff is not None else config.llm_max_backoff
)
extraction_response_json, call_usage = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="memory_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=True, # Get raw JSON, we'll validate leniently
return_usage=True,
)
@@ -1019,6 +1013,7 @@ async def _extract_facts_with_auto_split(
context: str,
llm_config: LLMConfig,
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
Extract facts from a chunk with automatic splitting if output exceeds token limits.
@@ -1034,6 +1029,7 @@ async def _extract_facts_with_auto_split(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
Tuple of (facts list, token usage) extracted from the chunk (possibly from sub-chunks)
@@ -1052,6 +1048,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
except OutputTooLongError:
# Output exceeded token limits - split the chunk in half and retry
@@ -1096,6 +1093,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
),
_extract_facts_with_auto_split(
chunk=second_half,
@@ -1105,6 +1103,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
),
]
@@ -1128,6 +1127,7 @@ async def extract_facts_from_text(
llm_config: LLMConfig,
agent_name: str,
context: str = "",
extract_opinions: bool = False,
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
"""
Extract semantic facts from conversational or narrative text using LLM.
@@ -1144,6 +1144,7 @@ async def extract_facts_from_text(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and bank facts (no opinions)
Returns:
Tuple of (facts, chunks, usage) where:
@@ -1171,6 +1172,7 @@ async def extract_facts_from_text(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
for i, chunk in enumerate(chunks)
]
@@ -1202,7 +1204,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, extract_opinions: bool = False
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
Extract facts from multiple content items in parallel.
@@ -1217,6 +1219,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)
extract_opinions: If True, extract only opinions; otherwise world/bank facts
Returns:
Tuple of (extracted_facts, chunks_metadata, usage)
@@ -1235,6 +1238,7 @@ async def extract_facts_from_contents(
context=item.context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
fact_extraction_tasks.append(task)
@@ -101,8 +101,11 @@ async def retain_batch(
# Step 1: Extract facts from all contents
step_start = time.time()
extract_opinions = fact_type_override == "opinion"
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, extract_opinions
)
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"
)
@@ -19,6 +19,7 @@ async def extract_facts(
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
extract_opinions: bool = False,
) -> 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
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
Tuple of (facts, chunks) where:
@@ -53,6 +55,7 @@ async def extract_facts(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
if not facts:
+11 -25
View File
@@ -140,6 +140,13 @@ def main():
args.port = DEFAULT_DAEMON_PORT
args.host = "127.0.0.1" # Only bind to localhost for security
# Force CPU mode for daemon to avoid macOS MPS/XPC issues
# MPS (Metal Performance Shaders) has unstable XPC connections in background processes
# that can cause assertion failures and process crashes at the C++ level
# (which Python exception handlers cannot catch)
os.environ["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
os.environ["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
# Check if another daemon is already running
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
@@ -176,40 +183,19 @@ def main():
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_max_retries=config.llm_max_retries,
llm_initial_backoff=config.llm_initial_backoff,
llm_max_backoff=config.llm_max_backoff,
llm_timeout=config.llm_timeout,
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
retain_llm_base_url=config.retain_llm_base_url,
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
retain_llm_max_retries=config.retain_llm_max_retries,
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
retain_llm_max_backoff=config.retain_llm_max_backoff,
retain_llm_timeout=config.retain_llm_timeout,
reflect_llm_provider=config.reflect_llm_provider,
reflect_llm_api_key=config.reflect_llm_api_key,
reflect_llm_model=config.reflect_llm_model,
reflect_llm_base_url=config.reflect_llm_base_url,
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
reflect_llm_max_retries=config.reflect_llm_max_retries,
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
reflect_llm_timeout=config.reflect_llm_timeout,
consolidation_llm_provider=config.consolidation_llm_provider,
consolidation_llm_api_key=config.consolidation_llm_api_key,
consolidation_llm_model=config.consolidation_llm_model,
consolidation_llm_base_url=config.consolidation_llm_base_url,
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
consolidation_llm_timeout=config.consolidation_llm_timeout,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
@@ -239,6 +225,7 @@ def main():
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_custom_instructions=config.retain_custom_instructions,
retain_observations_async=config.retain_observations_async,
enable_observations=config.enable_observations,
consolidation_batch_size=config.consolidation_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
@@ -366,7 +353,6 @@ def main():
# Start idle checker in daemon mode
if idle_middleware is not None:
# Start the idle checker in a background thread with its own event loop
import logging
import threading
def run_idle_checker():
@@ -377,12 +363,12 @@ def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(idle_middleware._check_idle())
except Exception as e:
logging.error(f"Idle checker error: {e}", exc_info=True)
except Exception:
pass
threading.Thread(target=run_idle_checker, daemon=True).start()
uvicorn.run(**uvicorn_config)
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
if __name__ == "__main__":
+12 -31
View File
@@ -32,9 +32,6 @@ class MCPToolsConfig:
# How to resolve bank_id for operations
bank_id_resolver: Callable[[], str | None]
# How to resolve API key for tenant auth (optional)
api_key_resolver: Callable[[], str | None] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
@@ -49,16 +46,6 @@ class MCPToolsConfig:
retain_fire_and_forget: bool = False # If True, use asyncio.create_task pattern
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
"""Create RequestContext with API key from resolver if available.
This enables tenant auth to work with MCP tools by propagating
the Bearer token from the MCP middleware to the memory engine.
"""
api_key = config.api_key_resolver() if config.api_key_resolver else None
return RequestContext(api_key=api_key)
def parse_timestamp(timestamp: str) -> datetime | None:
"""Parse an ISO format timestamp string.
@@ -168,14 +155,12 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
@@ -211,17 +196,16 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
return f"Error: {error}"
contents = [content_dict]
request_context = _get_request_context(config)
if async_processing:
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=request_context
bank_id=target_bank, contents=contents, request_context=RequestContext()
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=request_context,
request_context=RequestContext(),
)
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
@@ -253,14 +237,12 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
@@ -298,7 +280,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=_get_request_context(config),
request_context=RequestContext(),
)
return recall_result.model_dump_json(indent=2)
@@ -329,7 +311,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=_get_request_context(config),
request_context=RequestContext(),
)
return recall_result.model_dump()
@@ -388,7 +370,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
query=query,
budget=budget_enum,
context=context,
request_context=_get_request_context(config),
request_context=RequestContext(),
)
return reflect_result.model_dump_json(indent=2)
@@ -441,7 +423,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig
query=query,
budget=budget_enum,
context=context,
request_context=_get_request_context(config),
request_context=RequestContext(),
)
return reflect_result.model_dump()
@@ -465,7 +447,7 @@ def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=_get_request_context(config))
banks = await memory.list_banks(request_context=RequestContext())
return json.dumps({"banks": banks}, indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
@@ -489,9 +471,8 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
request_context = _get_request_context(config)
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/mission if provided
if name is not None or mission is not None:
@@ -499,10 +480,10 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
bank_id,
name=name,
mission=mission,
request_context=request_context,
request_context=RequestContext(),
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
+3 -3
View File
@@ -189,7 +189,7 @@ class MetricsCollectorBase:
Args:
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
@@ -321,7 +321,7 @@ class MetricsCollector(MetricsCollectorBase):
pass
Args:
operation: Operation name (retain, recall, reflect, consolidation)
operation: Operation name (retain, recall, reflect, entity_observation)
bank_id: Memory bank ID
source: Source of the operation (api, reflect, internal)
budget: Optional budget level (low, mid, high)
@@ -371,7 +371,7 @@ class MetricsCollector(MetricsCollectorBase):
Args:
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
+1 -1
View File
@@ -40,7 +40,7 @@ class EmbeddedPostgres:
# Only set port if explicitly specified
if self.port is not None:
kwargs["port"] = self.port
self._pg0 = Pg0(**kwargs)
self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
return self._pg0
async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str:
+3 -7
View File
@@ -183,25 +183,21 @@ def main():
from ..extensions import TenantExtension, load_extension
# Load tenant extension BEFORE creating MemoryEngine so it can
# set correct schema context during task execution. Without this,
# _authenticate_tenant sees no extension and resets schema to "public",
# causing worker writes to land in the wrong schema.
tenant_extension = load_extension("TENANT", TenantExtension)
# Initialize MemoryEngine
# Workers use SyncTaskBackend because they execute tasks directly,
# they don't need to store tasks (they poll from DB)
memory = MemoryEngine(
run_migrations=False, # Workers don't run migrations
task_backend=SyncTaskBackend(),
tenant_extension=tenant_extension,
)
await memory.initialize()
print(f"Database connected: {config.database_url}")
# Load tenant extension for dynamic schema discovery
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
else:
+12 -23
View File
@@ -132,14 +132,6 @@ class WorkerPoller:
async def _claim_batch_for_schema(self, schema: str | None, limit: int) -> list[ClaimedTask]:
"""Claim tasks from a specific schema."""
try:
return await self._claim_batch_for_schema_inner(schema, limit)
except Exception as e:
logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema or 'public'}: {e}")
return []
async def _claim_batch_for_schema_inner(self, schema: str | None, limit: int) -> list[ClaimedTask]:
"""Inner implementation for claiming tasks from a specific schema."""
table = fq_table("async_operations", schema)
async with self._pool.acquire() as conn:
@@ -301,23 +293,20 @@ class WorkerPoller:
total_count = 0
for schema in schemas:
try:
table = fq_table("async_operations", schema)
table = fq_table("async_operations", schema)
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
""",
self._worker_id,
)
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
""",
self._worker_id,
)
# Parse "UPDATE N" to get count
count = int(result.split()[-1]) if result else 0
total_count += count
except Exception as e:
logger.warning(f"Worker {self._worker_id} failed to recover tasks for schema {schema or 'public'}: {e}")
# Parse "UPDATE N" to get count
count = int(result.split()[-1]) if result else 0
total_count += count
if total_count > 0:
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
+1 -7
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.2"
version = "0.4.1"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -34,7 +34,6 @@ dependencies = [
"opentelemetry-exporter-prometheus>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"google-auth>=2.0.0",
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
@@ -142,11 +141,6 @@ known-third-party = ["alembic"]
quote-style = "double"
indent-style = "space"
[tool.uv]
# Allow uv to search all configured indexes for packages, not just the first one
# This prevents dependency resolution failures when using pytorch index + PyPI
index-strategy = "unsafe-best-match"
[tool.ty]
# Type checking configuration
# ty is an extremely fast Python type checker from Astral (same team as ruff/uv)
@@ -0,0 +1,148 @@
"""
Tests for XPC error recovery in LocalSTCrossEncoder.
This tests the automatic reinitialization of the cross-encoder model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
class TestCrossEncoderXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTCrossEncoder."""
@pytest.fixture
def cross_encoder(self):
"""Create a LocalSTCrossEncoder instance."""
return LocalSTCrossEncoder(model_name="cross-encoder/ms-marco-TinyBERT-L-2-v2")
def test_is_xpc_error_detection(self, cross_encoder):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert cross_encoder._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert cross_encoder._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not cross_encoder._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_predict_with_xpc_recovery(self, cross_encoder):
"""Test that predict() recovers from XPC errors by reinitializing."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = cross_encoder._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track predict attempts
predict_attempts = []
original_predict = cross_encoder._model.predict
def mock_predict(*args, **kwargs):
predict_attempts.append(1)
# Only fail on first attempt
if len(predict_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_predict(*args, **kwargs)
# Mock the initial predict to fail, reinit happens, then new model succeeds
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should trigger XPC error on first attempt, then recover and succeed
result = await cross_encoder.predict([("query", "document")])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert isinstance(result[0], float)
assert reinit_called # Should have reinitialized
assert len(predict_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_predict_fails_on_non_xpc_error(self, cross_encoder):
"""Test that predict() does not retry for non-XPC errors."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Create a mock that raises a non-XPC error
def mock_predict(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's predict method
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, cross_encoder):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the cross-encoder
await cross_encoder.initialize()
original_model = cross_encoder._model
assert original_model is not None
# Reinitialize
cross_encoder._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert cross_encoder._model is not None
assert cross_encoder._model is not original_model
# Should still work
result = await cross_encoder.predict([("test query", "test document")])
assert len(result) == 1
assert isinstance(result[0], float)
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, cross_encoder):
"""Test that XPC recovery gives up after max retries."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = cross_encoder._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(Exception) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value) or "Failed to recover" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
@@ -0,0 +1,148 @@
"""
Tests for XPC error recovery in LocalSTEmbeddings.
This tests the automatic reinitialization of the embedding model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.embeddings import LocalSTEmbeddings
class TestXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTEmbeddings."""
@pytest.fixture
def embeddings(self):
"""Create a LocalSTEmbeddings instance."""
return LocalSTEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
def test_is_xpc_error_detection(self, embeddings):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert embeddings._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert embeddings._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not embeddings._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_encode_with_xpc_recovery(self, embeddings):
"""Test that encode() recovers from XPC errors by reinitializing."""
# Initialize the embeddings
await embeddings.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = embeddings._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track encode attempts
encode_attempts = []
original_encode = embeddings._model.encode
def mock_encode(*args, **kwargs):
encode_attempts.append(1)
# Only fail on first attempt
if len(encode_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_encode(*args, **kwargs)
# Mock the initial encode to fail, reinit happens, then new model succeeds
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should trigger XPC error on first attempt, then recover and succeed
result = embeddings.encode(["test text"])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert len(result[0]) > 0 # Should have embedding vector
assert reinit_called # Should have reinitialized
assert len(encode_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_encode_fails_on_non_xpc_error(self, embeddings):
"""Test that encode() does not retry for non-XPC errors."""
# Initialize the embeddings
await embeddings.initialize()
# Create a mock that raises a non-XPC error
def mock_encode(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's encode method
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test text"])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, embeddings):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the embeddings
await embeddings.initialize()
original_model = embeddings._model
assert original_model is not None
# Reinitialize
embeddings._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert embeddings._model is not None
assert embeddings._model is not original_model
# Should still work
result = embeddings.encode(["test"])
assert len(result) == 1
assert len(result[0]) > 0
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, embeddings):
"""Test that XPC recovery gives up after max retries."""
# Initialize the embeddings
await embeddings.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = embeddings._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test"])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
@@ -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",
extract_opinions=False,
)
duration = time.time() - start_time
-44
View File
@@ -97,47 +97,3 @@ def test_path_parsing_logic():
bank_id, remaining = parse_path("/my-bank/some/path")
assert bank_id == "my-bank"
assert remaining == "/some/path"
@pytest.mark.asyncio
async def test_api_key_context_variable():
"""Test that API key context variable works correctly."""
from hindsight_api.api.mcp import get_current_api_key, _current_api_key
# Initially None
assert get_current_api_key() is None
# Set and verify
token = _current_api_key.set("test-api-key-123")
try:
assert get_current_api_key() == "test-api-key-123"
finally:
_current_api_key.reset(token)
# Back to None after reset
assert get_current_api_key() is None
@pytest.mark.asyncio
async def test_mcp_tools_propagate_api_key(mock_memory):
"""Test that MCP tools propagate API key to RequestContext."""
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id, _current_api_key
mcp_server = create_mcp_server(mock_memory)
tools = mcp_server._tool_manager._tools
# Set both bank_id and api_key context
bank_token = _current_bank_id.set("test-bank")
api_key_token = _current_api_key.set("test-bearer-token")
try:
retain_tool = tools["retain"]
result = await retain_tool.fn(content="test content", context="test_context", async_processing=False)
assert "successfully" in result.lower()
# Verify the memory was called with request_context containing api_key
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["request_context"].api_key == "test-bearer-token"
finally:
_current_bank_id.reset(bank_token)
_current_api_key.reset(api_key_token)
+3 -3
View File
@@ -358,7 +358,7 @@ class TestLLMMetrics:
collector.record_llm_call(
provider="gemini",
model="gemini-pro",
scope="memory",
scope="entity_observation",
duration=2.0,
success=True,
)
@@ -369,11 +369,11 @@ class TestLLMMetrics:
assert call_args[0][0] == 1
assert call_args[0][1]["provider"] == "gemini"
assert call_args[0][1]["model"] == "gemini-pro"
assert call_args[0][1]["scope"] == "memory"
assert call_args[0][1]["scope"] == "entity_observation"
def test_record_llm_call_different_scopes(self, collector):
"""Test recording LLM calls with different scopes."""
scopes = ["memory", "reflect", "consolidation", "answer"]
scopes = ["memory", "reflect", "entity_observation", "answer"]
for scope in scopes:
collector.llm_duration.record.reset_mock()
+1
View File
@@ -469,6 +469,7 @@ async def test_mixed_language_entities(memory, request_context):
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
include_entities=True,
request_context=request_context,
)
+242 -6
View File
@@ -91,13 +91,156 @@ async def test_entity_extraction_on_retain(memory, request_context):
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_regenerate_entity_observations(memory, request_context):
"""
Test explicit regeneration of summary for an entity.
"""
bank_id = f"test_regen_obs_{datetime.now(timezone.utc).timestamp()}"
try:
# Store facts about an entity
await memory.retain_async(
bank_id=bank_id,
content="Sarah is a product manager who loves user research and data analysis.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Find the Sarah entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
entity_row = await conn.fetchrow(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%sarah%'
LIMIT 1
""",
bank_id
)
if entity_row:
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Manually regenerate summary (via observations API for backwards compat)
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
request_context=request_context,
)
print(f"\n=== Regenerated Summary ===")
print(f"Created {len(created_ids)} summary for {entity_name}")
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
for obs in state.observations:
print(f" - {obs.text}")
# Verify summary was created
if len(created_ids) > 0:
assert len(state.observations) == 1, "Should have exactly 1 observation (the summary)"
print(f"Summary regenerated successfully")
else:
print(f"Note: No summary was regenerated")
else:
print(f"Note: No 'Sarah' entity was extracted")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_entity_state_retrieval(memory, request_context):
"""
Test retrieving entity state with facts.
"""
bank_id = f"test_entity_state_{datetime.now(timezone.utc).timestamp()}"
try:
# Store facts
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior software engineer.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Alice loves hiking and outdoor photography.",
context="hobbies",
event_date=datetime(2024, 1, 16, tzinfo=timezone.utc),
request_context=request_context,
)
# Find the Alice entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
entity_row = await conn.fetchrow(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%alice%'
LIMIT 1
""",
bank_id
)
assert entity_row is not None, "Alice entity should have been extracted"
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Check fact count
async with pool.acquire() as conn:
fact_count = await conn.fetchval(
"SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1",
entity_row['id']
)
print(f"\n=== Entity State Test ===")
print(f"Entity: {entity_name} (id: {entity_id})")
print(f"Linked facts: {fact_count}")
# Get entity state
state = await memory.get_entity_state(
bank_id, entity_id, entity_name, request_context=request_context
)
assert state.entity_id == entity_id
assert state.canonical_name == entity_name
print(f"Entity state retrieved successfully")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_search_with_include_entities(memory, request_context):
"""
Test that recall accepts include_entities parameter for backwards compatibility.
Test that search with include_entities=True returns entity information.
Note: Entity observations have been deprecated. This test verifies the parameter
is still accepted without errors.
This test verifies that:
1. Entities are extracted after retain
2. Entity info is returned in recall results with include_entities=True
"""
bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}"
@@ -106,6 +249,10 @@ async def test_search_with_include_entities(memory, request_context):
contents = [
"Alice is a data scientist who works on recommendation systems at Netflix.",
"Alice presented her research at the ML conference last month.",
"Alice is an expert in deep learning and neural networks.",
"Alice graduated from Stanford with a PhD in Computer Science.",
"Alice leads a team of 5 data scientists at Netflix.",
"Alice published a paper on collaborative filtering algorithms.",
]
for i, content in enumerate(contents):
@@ -120,7 +267,7 @@ async def test_search_with_include_entities(memory, request_context):
# Wait for background tasks
await memory.wait_for_background_tasks()
# Search with include_entities=True (should be accepted for backwards compatibility)
# Search with include_entities=True
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
@@ -132,9 +279,98 @@ async def test_search_with_include_entities(memory, request_context):
request_context=request_context,
)
# Verify recall works
assert len(result.results) > 0, "Should find some facts"
print(f"\n=== Search Results ===")
print(f"Found {len(result.results)} facts")
for fact in result.results:
print(f" - {fact.text}")
if fact.entities:
print(f" Entities: {', '.join(fact.entities)}")
# Verify results
assert len(result.results) > 0, "Should find some facts"
# Check if entities are included in facts
facts_with_entities = [f for f in result.results if f.entities]
assert len(facts_with_entities) > 0, "Some facts should have entity information"
print(f"{len(facts_with_entities)} facts have entity information")
# Check if entity info is returned
if result.entities:
print(f"Entity info included for {len(result.entities)} entities")
# Verify Alice entity is in results
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
print(f"Alice entity found: {name}")
assert alice_found, "Alice entity should be in recall results"
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_get_entity_state(memory, request_context):
"""
Test getting the full state of an entity.
"""
bank_id = f"test_entity_state_{datetime.now(timezone.utc).timestamp()}"
try:
# Store facts
await memory.retain_async(
bank_id=bank_id,
content="Bob is a frontend developer who specializes in React and TypeScript.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Find entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
entity_row = await conn.fetchrow(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%bob%'
LIMIT 1
""",
bank_id
)
if entity_row:
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Get entity state
state = await memory.get_entity_state(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
limit=10,
request_context=request_context,
)
print(f"\n=== Entity State for {entity_name} ===")
print(f"Entity ID: {state.entity_id}")
print(f"Canonical Name: {state.canonical_name}")
print(f"Observations: {len(state.observations)}")
for obs in state.observations:
print(f" - {obs.text}")
assert state.entity_id == entity_id, "Entity ID should match"
assert state.canonical_name == entity_name, "Canonical name should match"
finally:
# Cleanup
@@ -275,88 +275,3 @@ class TestReflectUsesReflectLLMConfig:
# Verify it's different from the retain config
assert engine._reflect_llm_config.model != engine._retain_llm_config.model
class TestRetryAndBackoffConfiguration:
"""Test retry and backoff configuration options."""
def test_global_retry_backoff_config_defaults(self):
"""Test that global retry/backoff settings have correct defaults."""
from hindsight_api.config import get_config
config = get_config()
# Verify global defaults
assert config.llm_max_retries == 10
assert config.llm_initial_backoff == 1.0
assert config.llm_max_backoff == 60.0
def test_per_operation_retry_backoff_config_from_env(self):
"""Test that per-operation retry/backoff settings are loaded from environment."""
from hindsight_api.config import clear_config_cache
# Set per-operation overrides
os.environ["HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"] = "3"
os.environ["HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"] = "2.0"
os.environ["HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"] = "120.0"
os.environ["HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"] = "5"
os.environ["HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"] = "1.5"
os.environ["HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"] = "90.0"
try:
clear_config_cache()
from hindsight_api.config import get_config
config = get_config()
# Verify retain overrides
assert config.retain_llm_max_retries == 3
assert config.retain_llm_initial_backoff == 2.0
assert config.retain_llm_max_backoff == 120.0
# Verify reflect overrides
assert config.reflect_llm_max_retries == 5
assert config.reflect_llm_initial_backoff == 1.5
assert config.reflect_llm_max_backoff == 90.0
# Verify global defaults remain unchanged
assert config.llm_max_retries == 10
assert config.llm_initial_backoff == 1.0
assert config.llm_max_backoff == 60.0
finally:
# Clean up
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES", None)
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF", None)
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF", None)
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", None)
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF", None)
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF", None)
clear_config_cache()
def test_per_operation_retry_backoff_fallback_to_global(self):
"""Test that per-operation settings fall back to global when not set."""
from hindsight_api.config import clear_config_cache, get_config
# Set only global values
os.environ["HINDSIGHT_API_LLM_MAX_RETRIES"] = "7"
os.environ["HINDSIGHT_API_LLM_INITIAL_BACKOFF"] = "3.0"
os.environ["HINDSIGHT_API_LLM_MAX_BACKOFF"] = "180.0"
try:
clear_config_cache()
config = get_config()
# Per-operation should be None (will fall back to global at runtime)
assert config.retain_llm_max_retries is None
assert config.retain_llm_initial_backoff is None
assert config.retain_llm_max_backoff is None
# Global values should be set
assert config.llm_max_retries == 7
assert config.llm_initial_backoff == 3.0
assert config.llm_max_backoff == 180.0
finally:
os.environ.pop("HINDSIGHT_API_LLM_MAX_RETRIES", None)
os.environ.pop("HINDSIGHT_API_LLM_INITIAL_BACKOFF", None)
os.environ.pop("HINDSIGHT_API_LLM_MAX_BACKOFF", None)
clear_config_cache()
+3
View File
@@ -16,6 +16,7 @@ async def test_retain_with_chunks(memory, request_context):
Test that retain function:
1. Stores facts with associated chunks
2. Recall returns chunk_id for each fact
3. Recall with include_entities=True also works (for compatibility)
"""
bank_id = f"test_chunks_{datetime.now(timezone.utc).timestamp()}"
document_id = "test_doc_123"
@@ -55,6 +56,7 @@ async def test_retain_with_chunks(memory, request_context):
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"], # Search for world facts
include_entities=False, # Disable entities for simpler test
include_chunks=True, # Enable chunks
max_chunk_tokens=8192,
request_context=request_context,
@@ -144,6 +146,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
include_entities=True,
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
+126 -1
View File
@@ -1,5 +1,5 @@
"""
Test reflect (think) function.
Test think function for opinion generation and consistency.
"""
import pytest
from datetime import datetime, timezone
@@ -7,6 +7,131 @@ from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
@pytest.mark.asyncio
async def test_think_opinion_consistency(memory, request_context):
"""
Test that think function:
1. Generates an opinion
2. Stores the opinion in the database
3. Returns consistent response on subsequent calls with the same query
"""
bank_id = f"test_think_{datetime.now(timezone.utc).timestamp()}"
try:
# Store some initial facts to give context for opinion formation
await memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer who has worked on 5 major projects. She always delivers on time and writes clean, well-documented code.",
context="performance review",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Bob recently joined the team. He missed his first deadline and his code had many bugs.",
context="performance review",
event_date=datetime(2024, 2, 1, tzinfo=timezone.utc),
request_context=request_context,
)
# First think call - should generate opinions
query = "Who is a more reliable engineer?"
result1 = await memory.reflect_async(
bank_id=bank_id,
query=query,
budget=Budget.LOW,
request_context=request_context,
)
print(f"\n=== First Think Call ===")
print(f"Answer: {result1.text}")
# Verify we got an answer
assert result1.text, "First think call should return an answer"
assert result1.based_on, "Should return based_on facts"
# Wait for background opinion processing tasks to complete
await memory.wait_for_background_tasks()
# Search for stored opinions to verify they were actually saved
pool = await memory._get_pool()
async with pool.acquire() as conn:
stored_opinions = await conn.fetch(
"""
SELECT id, text, confidence_score, fact_type
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'opinion'
ORDER BY created_at DESC
""",
bank_id
)
print(f"\n=== Stored Opinions in Database ===")
print(f"Total opinions stored: {len(stored_opinions)}")
for op in stored_opinions:
print(f" - {op['text']} (confidence: {op['confidence_score']:.2f})")
# Verify opinions were actually written to database
# NOTE: Opinion extraction may not always detect opinions depending on the LLM response format
if len(stored_opinions) > 0:
assert all(op['fact_type'] == 'opinion' for op in stored_opinions), "All stored items should have fact_type='opinion'"
print(f"✓ Opinions were successfully stored in database")
else:
print(f"⚠ Note: No opinions were extracted/stored (this can happen if the LLM response format doesn't trigger opinion extraction)")
# Second think call - should use the stored opinions
result2 = await memory.reflect_async(
bank_id=bank_id,
query=query,
budget=Budget.LOW,
request_context=request_context,
)
print(f"\n=== Second Think Call ===")
print(f"Answer: {result2.text}")
print(f"Existing opinions used: {len(result2.based_on.get('opinion', []))}")
for opinion in result2.based_on.get('opinion', []):
print(f" - {opinion.text}")
# Verify second call also got an answer
assert result2.text, "Second think call should return an answer"
# Verify second call used the stored opinions (if any were stored)
if len(stored_opinions) > 0:
assert len(result2.based_on.get('opinion', [])) > 0, "Second call should retrieve stored opinions"
# The responses should be consistent (both should mention the same person as more reliable)
# We'll do a basic check that they're not contradictory
text1_lower = result1.text.lower()
text2_lower = result2.text.lower()
print(f"\n=== Consistency Check ===")
# Check if Alice is mentioned as more reliable in first response
if 'alice' in text1_lower and ('reliable' in text1_lower or 'better' in text1_lower):
print("First response favors Alice")
# Second response should also favor Alice (consistency)
assert 'alice' in text2_lower, "Second response should also mention Alice"
print("Second response also mentions Alice - CONSISTENT ✓")
# Check if Bob is mentioned
if 'bob' in text1_lower:
print("First response mentions Bob")
if 'bob' in text2_lower:
print("Second response also mentions Bob - CONSISTENT ✓")
print(f"\n✅ Test passed - opinions were formed, stored, and used consistently")
finally:
# Clean up agent data
try:
await memory.delete_bank(bank_id, request_context=request_context)
except Exception as e:
print(f"Warning: Error during cleanup: {e}")
@pytest.mark.asyncio
async def test_think_without_prior_context(memory, request_context):
"""
@@ -1,244 +0,0 @@
"""
Test Vertex AI provider integration using native genai SDK.
"""
import os
from unittest.mock import MagicMock, patch
import pytest
# Skip all tests if google-auth not available
pytest.importorskip("google.auth")
def test_llm_wrapper_vertexai_missing_dependency():
"""Test error when google-auth is not available and service account key is set."""
from hindsight_api.engine import llm_wrapper
# VERTEXAI_AVAILABLE only matters when a service account key is provided
original_available = llm_wrapper.VERTEXAI_AVAILABLE
try:
llm_wrapper.VERTEXAI_AVAILABLE = False
with patch.dict(
os.environ,
{
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project",
"HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY": "/path/to/key.json",
},
clear=False,
):
from hindsight_api.config import clear_config_cache
clear_config_cache()
with pytest.raises(ValueError, match="google-auth"):
from hindsight_api.engine.llm_wrapper import LLMProvider
LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-001",
)
clear_config_cache()
finally:
llm_wrapper.VERTEXAI_AVAILABLE = original_available
def test_llm_wrapper_vertexai_missing_project_id():
"""Test error when project ID is not configured."""
with patch.dict(os.environ, {"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": ""}, clear=False):
from hindsight_api.config import clear_config_cache
clear_config_cache()
with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"):
from hindsight_api.engine.llm_wrapper import LLMProvider
LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-001",
)
clear_config_cache()
def test_llm_wrapper_vertexai_adc_auth():
"""Test Vertex AI with ADC authentication creates native genai client."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with patch.dict(
os.environ,
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
clear=False,
):
from hindsight_api.config import clear_config_cache
clear_config_cache()
# genai.Client handles ADC internally — just verify it creates the client
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
provider = LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-001",
)
assert provider.provider == "vertexai"
assert provider.model == "gemini-2.0-flash-001" # google/ prefix stripped
assert provider._gemini_client is not None
# Verify genai.Client was called with vertexai=True
mock_client_cls.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
)
clear_config_cache()
def test_llm_wrapper_vertexai_sa_auth():
"""Test Vertex AI with service account authentication passes credentials to genai client."""
from hindsight_api.engine.llm_wrapper import LLMProvider
mock_credentials = MagicMock()
with patch.dict(
os.environ,
{
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project",
"HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY": "/path/to/key.json",
},
clear=False,
):
from hindsight_api.config import clear_config_cache
clear_config_cache()
with patch(
"google.oauth2.service_account.Credentials.from_service_account_file",
return_value=mock_credentials,
):
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
provider = LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-001",
)
assert provider.provider == "vertexai"
assert provider._gemini_client is not None
# Verify credentials were passed to genai.Client
mock_client_cls.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
credentials=mock_credentials,
)
clear_config_cache()
def test_llm_wrapper_vertexai_strips_google_prefix():
"""Test that google/ prefix is stripped from model name for native SDK."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with patch.dict(
os.environ,
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
clear=False,
):
from hindsight_api.config import clear_config_cache
clear_config_cache()
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
provider = LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-lite-001",
)
assert provider.model == "gemini-2.0-flash-lite-001"
clear_config_cache()
def test_llm_wrapper_vertexai_no_prefix_model():
"""Test that model without google/ prefix is unchanged."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with patch.dict(
os.environ,
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
clear=False,
):
from hindsight_api.config import clear_config_cache
clear_config_cache()
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
provider = LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="gemini-2.0-flash-001",
)
assert provider.model == "gemini-2.0-flash-001"
clear_config_cache()
@pytest.mark.asyncio
@pytest.mark.skipif(
not os.getenv("HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"),
reason="Vertex AI integration tests require HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID",
)
async def test_vertexai_integration_actual_api():
"""
Integration test with actual Vertex AI API.
Requires:
- HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
- ADC or HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY
"""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="vertexai",
api_key="",
base_url="",
model="google/gemini-2.0-flash-001",
)
try:
# Simple test call
response = await provider.call(
messages=[{"role": "user", "content": "Say 'ok' and nothing else"}],
max_completion_tokens=10,
)
assert response is not None
assert isinstance(response, str)
assert len(response) > 0
finally:
await provider.cleanup()
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.2"
version = "0.4.1"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+3 -70
View File
@@ -500,8 +500,6 @@ pub fn delete(
pub fn consolidate(
client: &ApiClient,
bank_id: &str,
wait: bool,
poll_interval: u64,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -519,82 +517,17 @@ pub fn consolidate(
match response {
Ok(result) => {
let operation_id = result.operation_id.clone();
if output_format == OutputFormat::Pretty {
ui::print_success("Consolidation triggered");
println!(" {} {}", ui::dim("Operation ID:"), operation_id);
println!(" {} {}", ui::dim("Operation ID:"), result.operation_id);
if result.deduplicated {
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
}
println!();
println!("{}", ui::dim("Use 'hindsight operation get' to check the operation status."));
} else {
output::print_output(&result, output_format)?;
}
if !wait {
if output_format == OutputFormat::Pretty {
println!();
println!("{}", ui::dim("Use --wait to poll for completion, or 'hindsight operation get' to check status."));
}
return Ok(());
}
// Poll for completion
if output_format == OutputFormat::Pretty {
println!();
println!("{}", ui::dim(&format!("Polling every {}s for completion...", poll_interval)));
}
let start = std::time::Instant::now();
loop {
std::thread::sleep(std::time::Duration::from_secs(poll_interval));
let elapsed = start.elapsed().as_secs();
let ops_result = client.list_operations(bank_id, verbose);
match ops_result {
Ok(ops) => {
// Find the operation by ID
let op = ops.operations.iter().find(|o| o.id == operation_id);
match op.map(|o| o.status.as_str()) {
Some("completed") => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Consolidation completed ({}s)", elapsed));
}
break;
}
Some("failed") => {
let error_msg = op
.and_then(|o| o.error_message.as_ref())
.map(|s| s.as_str())
.unwrap_or("Unknown error");
if output_format == OutputFormat::Pretty {
ui::print_error(&format!("Consolidation failed: {}", error_msg));
}
std::process::exit(1);
}
Some(status) => {
if output_format == OutputFormat::Pretty {
println!("{} ({}s elapsed)", status, elapsed);
}
}
None => {
if output_format == OutputFormat::Pretty {
ui::print_warning(&format!("Operation {} not found in list", operation_id));
}
break;
}
}
}
Err(e) => {
if output_format == OutputFormat::Pretty {
ui::print_error(&format!("Failed to check operation status: {}", e));
}
return Err(e);
}
}
}
Ok(())
}
Err(e) => Err(e),
-141
View File
@@ -1,6 +1,4 @@
use anyhow::Result;
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
use std::collections::BTreeMap;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
@@ -9,17 +7,11 @@ pub fn list(
client: &ApiClient,
agent_id: &str,
query: Option<String>,
date: Option<String>,
limit: i32,
offset: i32,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
// If date filter is provided, use the date-aware listing
if date.is_some() {
return list_with_date(client, agent_id, date.as_deref(), verbose, output_format);
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching documents..."))
} else {
@@ -58,139 +50,6 @@ pub fn list(
}
}
/// List documents with date filtering
fn list_with_date(
client: &ApiClient,
bank_id: &str,
date_filter: Option<&str>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching all documents..."))
} else {
None
};
// Fetch all documents with pagination
let all_docs = fetch_all_documents(client, bank_id, verbose)?;
if let Some(mut sp) = spinner {
sp.finish();
}
// Parse the date filter
let target_date = parse_date_filter(date_filter)?;
// Filter and group documents by date
let mut by_date: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
let mut filtered_count = 0;
for doc in all_docs {
let created_at = doc.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("");
// Parse the date part (YYYY-MM-DD) from created_at
let doc_date = created_at.split('T').next().unwrap_or("");
// Apply date filter if specified
if let Some(ref target) = target_date {
let target_str = target.format("%Y-%m-%d").to_string();
if doc_date != target_str {
continue;
}
}
filtered_count += 1;
by_date.entry(doc_date.to_string()).or_default().push(doc);
}
// Output
if output_format == OutputFormat::Pretty {
let filter_desc = match date_filter {
None | Some("yesterday") => "yesterday".to_string(),
Some("today") => "today".to_string(),
Some("all") => "all dates".to_string(),
Some(d) => d.to_string(),
};
ui::print_info(&format!(
"Documents for bank '{}' (filter: {}, showing: {})",
bank_id, filter_desc, filtered_count
));
println!();
// Show documents grouped by date (reverse order - newest first)
for (date_str, docs) in by_date.iter().rev() {
println!(" {} ({} documents)", date_str, docs.len());
for doc in docs {
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0);
println!(" - {} ({} memories)", id, mem_count);
}
println!();
}
} else {
// JSON/YAML output - convert to a list structure
let output: Vec<serde_json::Value> = by_date.values().flatten().cloned().collect();
output::print_output(&output, output_format)?;
}
Ok(())
}
/// Fetch all documents with pagination
fn fetch_all_documents(
client: &ApiClient,
bank_id: &str,
verbose: bool,
) -> Result<Vec<serde_json::Value>> {
let mut all_docs = Vec::new();
let mut offset = 0;
let limit = 500;
loop {
let response = client.list_documents(bank_id, None, Some(limit), Some(offset), verbose)?;
if response.items.is_empty() {
break;
}
// Convert Map<String, Value> to Value for each item
for item in response.items {
all_docs.push(serde_json::Value::Object(item));
}
offset += limit;
// Check if we've fetched everything
if all_docs.len() >= response.total as usize {
break;
}
}
Ok(all_docs)
}
/// Parse date filter string into a NaiveDate
fn parse_date_filter(filter: Option<&str>) -> Result<Option<NaiveDate>> {
match filter {
None | Some("yesterday") => {
// Default to yesterday
Ok(Some(Utc::now().date_naive() - ChronoDuration::days(1)))
}
Some("today") => Ok(Some(Utc::now().date_naive())),
Some("all") => Ok(None), // No filtering
Some(date_str) => {
// Try to parse as YYYY-MM-DD
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map(Some)
.map_err(|e| anyhow::anyhow!("Invalid date format '{}': {}. Use YYYY-MM-DD, 'yesterday', 'today', or 'all'", date_str, e))
}
}
}
pub fn get(
client: &ApiClient,
agent_id: &str,
+4 -16
View File
@@ -260,14 +260,6 @@ enum BankCommands {
Consolidate {
/// Bank ID
bank_id: String,
/// Wait for consolidation to complete (poll for status)
#[arg(long)]
wait: bool,
/// Poll interval in seconds (only used with --wait)
#[arg(long, default_value = "10")]
poll_interval: u64,
},
/// Clear all observations for a bank
@@ -449,10 +441,6 @@ enum DocumentCommands {
#[arg(short = 'q', long)]
query: Option<String>,
/// Filter by date (yesterday, today, YYYY-MM-DD, or all)
#[arg(short = 'd', long)]
date: Option<String>,
/// Maximum number of results
#[arg(short = 'l', long, default_value = "100")]
limit: i32,
@@ -766,8 +754,8 @@ fn run() -> Result<()> {
BankCommands::Delete { bank_id, yes } => {
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
}
BankCommands::Consolidate { bank_id, wait, poll_interval } => {
commands::bank::consolidate(&client, &bank_id, wait, poll_interval, verbose, output_format)
BankCommands::Consolidate { bank_id } => {
commands::bank::consolidate(&client, &bank_id, verbose, output_format)
}
BankCommands::ClearObservations { bank_id, yes } => {
commands::bank::clear_observations(&client, &bank_id, yes, verbose, output_format)
@@ -804,8 +792,8 @@ fn run() -> Result<()> {
// Document commands
Commands::Document(doc_cmd) => match doc_cmd {
DocumentCommands::List { bank_id, query, date, limit, offset } => {
commands::document::list(&client, &bank_id, query, date, limit, offset, verbose, output_format)
DocumentCommands::List { bank_id, query, limit, offset } => {
commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format)
}
DocumentCommands::Get { bank_id, document_id } => {
commands::document::get(&client, &bank_id, &document_id, verbose, output_format)
@@ -7,14 +7,14 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
__version__ = "0.0.7"
__version__ = "0.4.1"
# import apis into sdk package
from hindsight_client_api.api.banks_api import BanksApi
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -1644,7 +1644,7 @@ class MemoryApi:
) -> RecallResponse:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed - `opinion`: The bank's formed beliefs, perspectives, and viewpoints Set `include_entities=true` to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -1720,7 +1720,7 @@ class MemoryApi:
) -> ApiResponse[RecallResponse]:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed - `opinion`: The bank's formed beliefs, perspectives, and viewpoints Set `include_entities=true` to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -1796,7 +1796,7 @@ class MemoryApi:
) -> RESTResponseType:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed - `opinion`: The bank's formed beliefs, perspectives, and viewpoints Set `include_entities=true` to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -1950,7 +1950,7 @@ class MemoryApi:
) -> ReflectResponse:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -2026,7 +2026,7 @@ class MemoryApi:
) -> ApiResponse[ReflectResponse]:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -2102,7 +2102,7 @@ class MemoryApi:
) -> RESTResponseType:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -489,7 +489,7 @@ class Configuration:
return "Python SDK Debug Report:\n"\
"OS: {env}\n"\
"Python Version: {pyversion}\n"\
"Version of the API: 0.4.2\n"\
"Version of the API: 0.4.0\n"\
"SDK Package Version: 0.0.7".\
format(env=sys.platform, pyversion=sys.version)
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -6,7 +6,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
@@ -5,7 +5,7 @@
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.2
The version of the OpenAPI document: 0.4.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.

Some files were not shown because too many files have changed in this diff Show More