fleet-memory/hindsight-clients/python/hindsight_client/hindsight_client.py
Nicolò Boschi 90e370ef35
fix: misc fixes for observations and mental models (#209)
* fix: misc fixes for observations and mental models

* feat: improve graph retrieval for observations

- Update LinkExpansionRetriever to traverse through source_memory_ids
  for observation entity connections (avoiding data duplication)
- Remove entity link copy from world facts to observations in consolidator
- Add tests for link expansion graph retrieval
- Add directives_applied field to ReflectResult
- Include user's other changes (CLI, docs, client updates)

* fix: CI test failures

- Add mental_model_id parameter to create_mental_model function
- Fix ToolCallTrace not including reason field from ToolCall
- Improve test_link_expansion_observation_graph_retrieval to wait for consolidation with retry

* chore: reduce link expansion log verbosity

* Revert "chore: reduce link expansion log verbosity"

This reverts commit 3ce759391cead1012157785fa78fef16ef9bfe3b.

* feat: add semantic/temporal/entity links as fallback in graph retrieval

- Add fallback query for semantic, temporal, and entity links from memory_links
- Check both directions (outgoing and incoming links)
- Weight fallback results at 0.5x to prioritize entity links via unit_entities
- Fixes graph retrieval returning 0 when data has cross-cluster temporal connections

* fix: enable observations fixture for link expansion test

- Add enable_observations fixture to ensure observations are created
- Increase wait time from 10 to 30 seconds for CI reliability
2026-01-27 15:37:57 +01:00

788 lines
27 KiB
Python

"""
Clean, pythonic wrapper for the Hindsight API client.
This file is MAINTAINED and NOT auto-generated. It provides a high-level,
easy-to-use interface on top of the auto-generated OpenAPI client.
"""
import asyncio
from datetime import datetime
from typing import Any, Literal
import hindsight_client_api
from hindsight_client_api.api import banks_api, directives_api, memory_api, mental_models_api
from hindsight_client_api.models import (
memory_item,
recall_request,
reflect_request,
retain_request,
)
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.retain_response import RetainResponse
def _run_async(coro):
"""Run an async coroutine synchronously."""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
class Hindsight:
"""
High-level, easy-to-use Hindsight API client.
Example:
```python
from hindsight_client import Hindsight
# Without authentication
client = Hindsight(base_url="http://localhost:8888")
# With API key authentication
client = Hindsight(base_url="http://localhost:8888", api_key="your-api-key")
# Store a memory
client.retain(bank_id="alice", content="Alice loves AI")
# Recall memories
response = client.recall(bank_id="alice", query="What does Alice like?")
for r in response.results:
print(r.text)
# Generate contextual answer
answer = client.reflect(bank_id="alice", query="What are my interests?")
```
"""
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 30.0):
"""
Initialize the Hindsight client.
Args:
base_url: The base URL of the Hindsight API server
api_key: Optional API key for authentication (sent as Bearer token)
timeout: Request timeout in seconds (default: 30.0)
"""
config = hindsight_client_api.Configuration(host=base_url, access_token=api_key)
self._api_client = hindsight_client_api.ApiClient(config)
if api_key:
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
self._memory_api = memory_api.MemoryApi(self._api_client)
self._banks_api = banks_api.BanksApi(self._api_client)
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
self._directives_api = directives_api.DirectivesApi(self._api_client)
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
def close(self):
"""Close the API client (sync version - use aclose() in async code)."""
if self._api_client:
try:
loop = asyncio.get_running_loop()
# We're in an async context - schedule but don't wait
# The caller should use aclose() instead
loop.create_task(self._api_client.close())
except RuntimeError:
# No running loop - safe to run synchronously
_run_async(self._api_client.close())
async def aclose(self):
"""Close the API client (async version)."""
if self._api_client:
await self._api_client.close()
# Simplified methods for main operations
def retain(
self,
bank_id: str,
content: str,
timestamp: datetime | None = None,
context: str | None = None,
document_id: str | None = None,
metadata: dict[str, str] | None = None,
entities: list[dict[str, str]] | None = None,
tags: list[str] | None = None,
) -> RetainResponse:
"""
Store a single memory (simplified interface).
Args:
bank_id: The memory bank ID
content: Memory content
timestamp: Optional event timestamp
context: Optional context description
document_id: Optional document ID for grouping
metadata: Optional user-defined metadata
entities: Optional list of entities [{"text": "...", "type": "..."}]
tags: Optional list of tags for filtering memories during recall/reflect
Returns:
RetainResponse with success status
"""
return self.retain_batch(
bank_id=bank_id,
items=[
{
"content": content,
"timestamp": timestamp,
"context": context,
"metadata": metadata,
"entities": entities,
"tags": tags,
}
],
document_id=document_id,
)
def retain_batch(
self,
bank_id: str,
items: list[dict[str, Any]],
document_id: str | None = None,
document_tags: list[str] | None = None,
retain_async: bool = False,
) -> RetainResponse:
"""
Store multiple memories in batch.
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
Returns:
RetainResponse with success status and item count
"""
from hindsight_client_api.models.entity_input import EntityInput
memory_items = []
for item in items:
entities = None
if item.get("entities"):
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
timestamp=item.get("timestamp"),
context=item.get("context"),
metadata=item.get("metadata"),
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
entities=entities,
tags=item.get("tags"),
)
)
request_obj = retain_request.RetainRequest(
items=memory_items,
async_=retain_async,
document_tags=document_tags,
)
return _run_async(self._memory_api.retain_memories(bank_id, request_obj))
def recall(
self,
bank_id: str,
query: str,
types: list[str] | None = None,
max_tokens: int = 4096,
budget: str = "mid",
trace: bool = False,
query_timestamp: str | None = None,
include_entities: bool = False,
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> RecallResponse:
"""
Recall memories using semantic similarity.
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
trace: Enable trace output (default: False)
query_timestamp: Optional ISO format date string (e.g., '2023-05-30T23:40:00')
include_entities: Include entity observations in results (default: False)
max_entity_tokens: Maximum tokens for entity observations (default: 500)
include_chunks: Include raw text chunks in results (default: False)
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
RecallResponse with results, optional entities, optional chunks, and optional trace
"""
from hindsight_client_api.models import chunk_include_options, entity_include_options, include_options
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens)
if include_entities
else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
)
request_obj = recall_request.RecallRequest(
query=query,
types=types,
budget=budget,
max_tokens=max_tokens,
trace=trace,
query_timestamp=query_timestamp,
include=include_opts,
tags=tags,
tags_match=tags_match,
)
return _run_async(self._memory_api.recall_memories(bank_id, request_obj))
def reflect(
self,
bank_id: str,
query: str,
budget: str = "low",
context: str | None = None,
max_tokens: int | None = None,
response_schema: dict[str, Any] | None = None,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories.
Args:
bank_id: The memory bank ID
query: The question or prompt
budget: Budget level for reflection - "low", "mid", or "high" (default: "low")
context: Optional additional context
max_tokens: Maximum tokens for the response (server default: 4096)
response_schema: Optional JSON Schema for structured output. When provided,
the response will include a 'structured_output' field with the LLM
response parsed according to this schema.
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
ReflectResponse with answer text, optionally facts used, and optionally
structured_output if response_schema was provided
"""
request_obj = reflect_request.ReflectRequest(
query=query,
budget=budget,
context=context,
max_tokens=max_tokens,
response_schema=response_schema,
tags=tags,
tags_match=tags_match,
)
return _run_async(self._memory_api.reflect(bank_id, request_obj))
def list_memories(
self,
bank_id: str,
type: str | None = None,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
) -> ListMemoryUnitsResponse:
"""List memory units with pagination."""
return _run_async(
self._memory_api.list_memories(
bank_id=bank_id,
type=type,
q=search_query,
limit=limit,
offset=offset,
)
)
def create_bank(
self,
bank_id: str,
name: str | None = None,
mission: str | None = None,
disposition: dict[str, float] | None = None,
) -> BankProfileResponse:
"""Create or update a memory bank.
Args:
bank_id: Unique identifier for the bank
name: Human-readable display name
mission: Instructions guiding what Hindsight should learn and remember (for mental models)
disposition: Optional disposition traits (skepticism, literalism, empathy)
"""
from hindsight_client_api.models import create_bank_request, disposition_traits
disposition_obj = None
if disposition:
disposition_obj = disposition_traits.DispositionTraits(**disposition)
request_obj = create_bank_request.CreateBankRequest(
name=name,
mission=mission,
disposition=disposition_obj,
)
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
def set_mission(
self,
bank_id: str,
mission: str,
) -> BankProfileResponse:
"""
Set or update the mission for a memory bank.
Args:
bank_id: The memory bank ID
mission: The mission text describing the agent's purpose
Returns:
BankProfileResponse with updated bank profile
"""
from hindsight_client_api.models import create_bank_request
request_obj = create_bank_request.CreateBankRequest(mission=mission)
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
# Async methods (native async, no _run_async wrapper)
async def aretain_batch(
self,
bank_id: str,
items: list[dict[str, Any]],
document_id: str | None = None,
document_tags: list[str] | None = None,
retain_async: bool = False,
) -> RetainResponse:
"""
Store multiple memories in batch (async).
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
Returns:
RetainResponse with success status and item count
"""
from hindsight_client_api.models.entity_input import EntityInput
memory_items = []
for item in items:
entities = None
if item.get("entities"):
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
timestamp=item.get("timestamp"),
context=item.get("context"),
metadata=item.get("metadata"),
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
entities=entities,
tags=item.get("tags"),
)
)
request_obj = retain_request.RetainRequest(
items=memory_items,
async_=retain_async,
document_tags=document_tags,
)
return await self._memory_api.retain_memories(bank_id, request_obj)
async def aretain(
self,
bank_id: str,
content: str,
timestamp: datetime | None = None,
context: str | None = None,
document_id: str | None = None,
metadata: dict[str, str] | None = None,
entities: list[dict[str, str]] | None = None,
tags: list[str] | None = None,
) -> RetainResponse:
"""
Store a single memory (async).
Args:
bank_id: The memory bank ID
content: Memory content
timestamp: Optional event timestamp
context: Optional context description
document_id: Optional document ID for grouping
metadata: Optional user-defined metadata
entities: Optional list of entities [{"text": "...", "type": "..."}]
tags: Optional list of tags for filtering memories during recall/reflect
Returns:
RetainResponse with success status
"""
return await self.aretain_batch(
bank_id=bank_id,
items=[
{
"content": content,
"timestamp": timestamp,
"context": context,
"metadata": metadata,
"entities": entities,
"tags": tags,
}
],
document_id=document_id,
)
async def arecall(
self,
bank_id: str,
query: str,
types: list[str] | None = None,
max_tokens: int = 4096,
budget: str = "mid",
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> list[RecallResult]:
"""
Recall memories using semantic similarity (async).
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
List of RecallResult objects
"""
request_obj = recall_request.RecallRequest(
query=query,
types=types,
budget=budget,
max_tokens=max_tokens,
trace=False,
tags=tags,
tags_match=tags_match,
)
response = await self._memory_api.recall_memories(bank_id, request_obj)
return response.results if hasattr(response, "results") else []
async def areflect(
self,
bank_id: str,
query: str,
budget: str = "low",
context: str | None = None,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories (async).
Args:
bank_id: The memory bank ID
query: The question or prompt
budget: Budget level for reflection - "low", "mid", or "high" (default: "low")
context: Optional additional context
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
ReflectResponse with answer text and optionally facts used
"""
request_obj = reflect_request.ReflectRequest(
query=query,
budget=budget,
context=context,
tags=tags,
tags_match=tags_match,
)
return await self._memory_api.reflect(bank_id, request_obj)
# Mental Models methods
def create_mental_model(
self,
bank_id: str,
name: str,
source_query: str,
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
):
"""
Create a mental model (runs reflect in background).
Args:
bank_id: The memory bank ID
name: Human-readable name for the mental model
source_query: The query to run to generate content
tags: Optional tags for filtering during retrieval
max_tokens: Optional maximum tokens for the mental model content
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
Returns:
CreateMentalModelResponse with operation_id
"""
from hindsight_client_api.models import create_mental_model_request, mental_model_trigger
trigger_obj = None
if trigger:
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
request_obj = create_mental_model_request.CreateMentalModelRequest(
name=name,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
trigger=trigger_obj,
)
return _run_async(self._mental_models_api.create_mental_model(bank_id, request_obj))
def list_mental_models(self, bank_id: str, tags: list[str] | None = None):
"""
List all mental models in a bank.
Args:
bank_id: The memory bank ID
tags: Optional tags to filter by
Returns:
ListMentalModelsResponse with items
"""
return _run_async(self._mental_models_api.list_mental_models(bank_id, tags=tags))
def get_mental_model(self, bank_id: str, mental_model_id: str):
"""
Get a specific mental model.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
Returns:
MentalModelResponse
"""
return _run_async(self._mental_models_api.get_mental_model(bank_id, mental_model_id))
def refresh_mental_model(self, bank_id: str, mental_model_id: str):
"""
Refresh a mental model to update with current knowledge.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
Returns:
RefreshMentalModelResponse with operation_id
"""
return _run_async(self._mental_models_api.refresh_mental_model(bank_id, mental_model_id))
def update_mental_model(
self,
bank_id: str,
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
):
"""
Update a mental model's metadata.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
name: Optional new name
source_query: Optional new source query
tags: Optional new tags
max_tokens: Optional new max tokens
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
Returns:
MentalModelResponse
"""
from hindsight_client_api.models import mental_model_trigger, update_mental_model_request
trigger_obj = None
if trigger:
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
request_obj = update_mental_model_request.UpdateMentalModelRequest(
name=name,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
trigger=trigger_obj,
)
return _run_async(self._mental_models_api.update_mental_model(bank_id, mental_model_id, request_obj))
def delete_mental_model(self, bank_id: str, mental_model_id: str):
"""
Delete a mental model.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
"""
return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id))
# Directives methods
def create_directive(
self,
bank_id: str,
name: str,
content: str,
priority: int = 0,
is_active: bool = True,
tags: list[str] | None = None,
):
"""
Create a directive (hard rule for reflect).
Args:
bank_id: The memory bank ID
name: Human-readable name for the directive
content: The directive content/rules
priority: Priority level (higher = injected first)
is_active: Whether the directive is active
tags: Optional tags for filtering
Returns:
DirectiveResponse
"""
from hindsight_client_api.models import create_directive_request
request_obj = create_directive_request.CreateDirectiveRequest(
name=name,
content=content,
priority=priority,
is_active=is_active,
tags=tags,
)
return _run_async(self._directives_api.create_directive(bank_id, request_obj))
def list_directives(self, bank_id: str, tags: list[str] | None = None):
"""
List all directives in a bank.
Args:
bank_id: The memory bank ID
tags: Optional tags to filter by
Returns:
ListDirectivesResponse with items
"""
return _run_async(self._directives_api.list_directives(bank_id, tags=tags))
def get_directive(self, bank_id: str, directive_id: str):
"""
Get a specific directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
Returns:
DirectiveResponse
"""
return _run_async(self._directives_api.get_directive(bank_id, directive_id))
def update_directive(
self,
bank_id: str,
directive_id: str,
name: str | None = None,
content: str | None = None,
priority: int | None = None,
is_active: bool | None = None,
tags: list[str] | None = None,
):
"""
Update a directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
name: Optional new name
content: Optional new content
priority: Optional new priority
is_active: Optional new active status
tags: Optional new tags
Returns:
DirectiveResponse
"""
from hindsight_client_api.models import update_directive_request
request_obj = update_directive_request.UpdateDirectiveRequest(
name=name,
content=content,
priority=priority,
is_active=is_active,
tags=tags,
)
return _run_async(self._directives_api.update_directive(bank_id, directive_id, request_obj))
def delete_directive(self, bank_id: str, directive_id: str):
"""
Delete a directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
"""
return _run_async(self._directives_api.delete_directive(bank_id, directive_id))
def delete_bank(self, bank_id: str):
"""
Delete a memory bank.
Args:
bank_id: The memory bank ID
"""
return _run_async(self._banks_api.delete_bank(bank_id))