Compare commits
2
Commits
ma
..
versioning
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c84e33100 | ||
|
|
45aa8b1e93 |
@@ -26,7 +26,6 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
|
||||
# Database (Optional - uses embedded pg0 by default)
|
||||
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
|
||||
|
||||
# Embeddings Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
|
||||
@@ -45,8 +45,6 @@ hindsight-docs/static/llms-full.txt
|
||||
|
||||
hindsight-dev/benchmarks/locomo/results/
|
||||
hindsight-dev/benchmarks/longmemeval/results/
|
||||
hindsight-dev/benchmarks/consolidation/results/
|
||||
benchmarks/results/
|
||||
hindsight-cli/target
|
||||
hindsight-clients/rust/target
|
||||
.claude
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||
|
||||
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud)
|
||||
|
||||
@@ -17,31 +17,55 @@
|
||||
|
||||
## What is Hindsight?
|
||||
|
||||
Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.
|
||||
Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
|
||||
|
||||
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
|
||||
|
||||
<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>
|
||||
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
|
||||
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
|
||||
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
|
||||
|
||||
It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
|
||||
## How is Hindsight Different From Other Memory Systems?
|
||||
|
||||

|
||||
|
||||
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
|
||||
|
||||
- **World:** Facts about the world ("The stove gets hot")
|
||||
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
|
||||
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
|
||||
|
||||
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
|
||||
|
||||
Hindsight provides three simple methods to interact with the system:
|
||||
|
||||
- **Retain:** Provide information to Hindsight that you want it to remember
|
||||
- **Recall:** Retrieve memories from Hindsight
|
||||
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
|
||||
|
||||
### Agent Memory That Learns
|
||||
|
||||
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
|
||||
|
||||
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
|
||||
|
||||
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
|
||||
|
||||
---
|
||||
|
||||
## Memory Performance & Accuracy
|
||||
|
||||
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
|
||||
Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational
|
||||
AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here:
|
||||
|
||||

|
||||
|
||||
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
|
||||
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
|
||||
|
||||
## Adding Hindsight to Your AI Agents
|
||||
|
||||
The easiest way use Hindsight with an existing agent is with the LLM Wrapper. You can add memory to your agent with 2 lines of code. That will swap your current LLM client out with the Hindsight wrapper. After that, memories will be stored and retrieved automatically as you make LLM calls.
|
||||
|
||||
If you need more control over how and when your agent stores and recalls memories, there's also a simple API you can integrate with using the SDKs or directly via HTTP.
|
||||
|
||||

|
||||
A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process.
|
||||
|
||||
The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -124,45 +148,8 @@ await client.recall('my-bank', 'What does Alice like?');
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
|
||||
Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.
|
||||
|
||||
### Per-User Memories and Chat History
|
||||
|
||||
One of the simpler use cases you can use Hindsight for is to personalize AI chatbots and other conversational agents by storing and recalling memories associated with individual users.
|
||||
|
||||
The requirements for this use case usually look something like this:
|
||||
|
||||

|
||||
|
||||
<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>
|
||||
|
||||
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Architecture & Operations
|
||||
|
||||

|
||||
|
||||
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
|
||||
|
||||
- **World:** Facts about the world ("The stove gets hot")
|
||||
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Mental Models:** Learned understanding of the agent's world formed by reflecting on raw memories and experiences.
|
||||
|
||||
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
|
||||
|
||||
Hindsight provides three simple methods to interact with the system:
|
||||
|
||||
- **Retain:** Provide information to Hindsight that you want it to remember
|
||||
- **Recall:** Retrieve memories from Hindsight
|
||||
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
|
||||
|
||||
### Retain
|
||||
|
||||
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
|
||||
@@ -221,7 +208,7 @@ The final output is trimmed as needed to fit within the token limit.
|
||||
|
||||
### Reflect
|
||||
|
||||
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world.
|
||||
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
|
||||
|
||||
For example, the `reflect` operation can be used to support use cases such as:
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.1
|
||||
appVersion: "0.4.1"
|
||||
version: 0.4.0
|
||||
appVersion: "0.4.0"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
|
||||
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
|
||||
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
|
||||
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
|
||||
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
|
||||
@@ -47,7 +46,6 @@ ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
@@ -67,7 +65,6 @@ ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
@@ -101,7 +98,6 @@ ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
||||
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
@@ -129,7 +125,6 @@ ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
|
||||
# Default values
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
DEFAULT_DATABASE_SCHEMA = "public"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
DEFAULT_LLM_MODEL = "gpt-5-mini"
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
@@ -137,13 +132,11 @@ DEFAULT_LLM_TIMEOUT = 120.0 # seconds
|
||||
|
||||
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)
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
@@ -184,7 +177,6 @@ DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (a
|
||||
# Observations defaults (consolidated knowledge from facts)
|
||||
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
||||
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
|
||||
DEFAULT_CONSOLIDATION_MAX_TOKENS = 1024 # Max tokens for recall when finding related observations
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
@@ -278,7 +270,6 @@ class HindsightConfig:
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
database_schema: str
|
||||
|
||||
# LLM (default, used as fallback for per-operation config)
|
||||
llm_provider: str
|
||||
@@ -307,7 +298,6 @@ class HindsightConfig:
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
embeddings_local_force_cpu: bool
|
||||
embeddings_tei_url: str | None
|
||||
embeddings_openai_base_url: str | None
|
||||
embeddings_cohere_base_url: str | None
|
||||
@@ -315,8 +305,6 @@ class HindsightConfig:
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_local_force_cpu: bool
|
||||
reranker_local_max_concurrent: int
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
@@ -348,7 +336,6 @@ class HindsightConfig:
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations: bool
|
||||
consolidation_batch_size: int
|
||||
consolidation_max_tokens: int
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
@@ -380,7 +367,6 @@ class HindsightConfig:
|
||||
return cls(
|
||||
# Database
|
||||
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
|
||||
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
|
||||
# LLM
|
||||
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
|
||||
llm_api_key=os.getenv(ENV_LLM_API_KEY),
|
||||
@@ -404,23 +390,12 @@ class HindsightConfig:
|
||||
# Embeddings
|
||||
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
|
||||
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
|
||||
embeddings_local_force_cpu=os.getenv(
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
|
||||
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
|
||||
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
|
||||
# Reranker
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
reranker_local_force_cpu=os.getenv(
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU, str(DEFAULT_RERANKER_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_local_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
@@ -469,9 +444,6 @@ class HindsightConfig:
|
||||
consolidation_batch_size=int(
|
||||
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
|
||||
),
|
||||
consolidation_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
|
||||
),
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
# Database connection pool
|
||||
@@ -543,7 +515,7 @@ class HindsightConfig:
|
||||
|
||||
def log_config(self) -> None:
|
||||
"""Log the current configuration (without sensitive values)."""
|
||||
logger.info(f"Database: {self.database_url} (schema: {self.database_schema})")
|
||||
logger.info(f"Database: {self.database_url}")
|
||||
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
|
||||
if self.retain_llm_provider or self.retain_llm_model:
|
||||
retain_provider = self.retain_llm_provider or self.llm_provider
|
||||
|
||||
@@ -144,14 +144,10 @@ async def run_consolidation_job(
|
||||
}
|
||||
|
||||
batch_num = 0
|
||||
last_progress_timings = {} # Track timings at last progress log
|
||||
while True:
|
||||
batch_num += 1
|
||||
batch_start = time.time()
|
||||
|
||||
# Snapshot timings at batch start for per-batch calculation
|
||||
batch_start_timings = perf.timings.copy()
|
||||
|
||||
# Fetch next batch of unconsolidated memories
|
||||
async with pool.acquire() as conn:
|
||||
t0 = time.time()
|
||||
@@ -221,44 +217,19 @@ async def run_consolidation_job(
|
||||
elif action == "skipped":
|
||||
stats["skipped"] += 1
|
||||
|
||||
# Log progress periodically with timing breakdown
|
||||
# Log progress periodically
|
||||
if stats["memories_processed"] % 10 == 0:
|
||||
# Calculate timing deltas since last progress log
|
||||
timing_parts = []
|
||||
for key in ["recall", "llm", "embedding", "db_write"]:
|
||||
if key in perf.timings:
|
||||
delta = perf.timings[key] - last_progress_timings.get(key, 0)
|
||||
timing_parts.append(f"{key}={delta:.2f}s")
|
||||
|
||||
timing_str = f" | {', '.join(timing_parts)}" if timing_parts else ""
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} progress: "
|
||||
f"{stats['memories_processed']}/{total_count} memories processed{timing_str}"
|
||||
f"{stats['memories_processed']}/{total_count} memories processed"
|
||||
)
|
||||
|
||||
# Update last progress snapshot
|
||||
last_progress_timings = perf.timings.copy()
|
||||
|
||||
batch_time = time.time() - batch_start
|
||||
perf.log(
|
||||
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
|
||||
f"(avg {batch_time / len(memories):.3f}s/memory)"
|
||||
)
|
||||
|
||||
# Log timing breakdown after each batch (delta from batch start)
|
||||
timing_parts = []
|
||||
for key in ["recall", "llm", "embedding", "db_write"]:
|
||||
if key in perf.timings:
|
||||
delta = perf.timings[key] - batch_start_timings.get(key, 0)
|
||||
timing_parts.append(f"{key}={delta:.3f}s")
|
||||
|
||||
if timing_parts:
|
||||
avg_per_memory = batch_time / len(memories) if memories else 0
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} batch {batch_num}/{len(memories)} memories: "
|
||||
f"{', '.join(timing_parts)} | avg={avg_per_memory:.3f}s/memory"
|
||||
)
|
||||
|
||||
# Build summary
|
||||
perf.log(
|
||||
f"[3] Results: {stats['memories_processed']} memories -> "
|
||||
@@ -668,27 +639,28 @@ async def _find_related_observations(
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find observations related to the given query using optimized recall.
|
||||
Find observations related to the given query using the full recall system.
|
||||
|
||||
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
|
||||
potentially related observations regardless of scope, so the LLM can
|
||||
decide on tag routing (same scope update vs cross-scope create).
|
||||
|
||||
Uses max_tokens to naturally limit observations (no artificial count limit).
|
||||
Includes source memories with dates for LLM context.
|
||||
This leverages:
|
||||
- Semantic search (embedding similarity)
|
||||
- BM25 text search (keyword matching)
|
||||
- Entity-based retrieval (shared entities)
|
||||
- Graph traversal (connected via entity links)
|
||||
|
||||
Returns:
|
||||
List of related observations with their tags, source memories, and dates
|
||||
List of related observations with their tags for LLM tag routing
|
||||
"""
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
|
||||
config = get_config()
|
||||
# Use recall to find related observations
|
||||
# NO tags parameter - we want ALL observations regardless of scope
|
||||
# Use low max_tokens since we only need observations, not memories
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
max_tokens=5000, # Token budget for observations
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
_quiet=True, # Suppress logging
|
||||
@@ -696,82 +668,43 @@ async def _find_related_observations(
|
||||
)
|
||||
|
||||
# If no observations returned, return empty list
|
||||
# When fact_type=["observation"], results come back in `results` field
|
||||
if not recall_result.results:
|
||||
return []
|
||||
|
||||
# Batch fetch all observations in a single query (no artificial limit)
|
||||
observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
|
||||
occurred_start, occurred_end, mentioned_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
|
||||
""",
|
||||
observation_ids,
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Build results list preserving recall order
|
||||
id_to_row = {row["id"]: row for row in rows}
|
||||
# Trust recall's relevance filtering - fetch full data for each observation
|
||||
results = []
|
||||
|
||||
for obs in recall_result.results:
|
||||
obs_id = uuid.UUID(obs.id)
|
||||
if obs_id not in id_to_row:
|
||||
continue
|
||||
|
||||
row = id_to_row[obs_id]
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
|
||||
# Fetch source memories to include their text and dates
|
||||
source_memory_ids = row["source_memory_ids"] or []
|
||||
source_memories = []
|
||||
|
||||
if source_memory_ids:
|
||||
source_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT text, occurred_start, occurred_end, mentioned_at, event_date
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5
|
||||
""",
|
||||
source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
|
||||
bank_id,
|
||||
)
|
||||
|
||||
for src_row in source_rows:
|
||||
source_memories.append(
|
||||
{
|
||||
"text": src_row["text"],
|
||||
"occurred_start": src_row["occurred_start"],
|
||||
"occurred_end": src_row["occurred_end"],
|
||||
"mentioned_at": src_row["mentioned_at"],
|
||||
"event_date": src_row["event_date"],
|
||||
}
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"] or 1,
|
||||
"tags": row["tags"] or [],
|
||||
"source_memories": source_memories,
|
||||
"occurred_start": row["occurred_start"],
|
||||
"occurred_end": row["occurred_end"],
|
||||
"mentioned_at": row["mentioned_at"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
# Fetch full observation data from DB to get history, source_memory_ids, tags
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'
|
||||
""",
|
||||
uuid.UUID(obs.id),
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if row:
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"] or 1,
|
||||
"history": history,
|
||||
"tags": row["tags"] or [], # Include tags for LLM tag routing
|
||||
"source_memory_ids": row["source_memory_ids"] or [],
|
||||
"similarity": 1.0, # Retrieved via recall so assumed relevant
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -799,43 +732,14 @@ async def _consolidate_with_llm(
|
||||
- {"action": "create", "text": "...", "reason": "..."}
|
||||
- [] if fact is purely ephemeral (no durable knowledge)
|
||||
"""
|
||||
# Format observations as JSON with source memories and dates
|
||||
# Format observations WITH their tags (or "None" if empty)
|
||||
if observations:
|
||||
obs_list = []
|
||||
for obs in observations:
|
||||
obs_data = {
|
||||
"id": str(obs["id"]),
|
||||
"text": obs["text"],
|
||||
"proof_count": obs["proof_count"],
|
||||
"tags": obs["tags"],
|
||||
"created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
|
||||
"updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
|
||||
}
|
||||
|
||||
# Include temporal info if available
|
||||
if obs.get("occurred_start"):
|
||||
obs_data["occurred_start"] = obs["occurred_start"].isoformat()
|
||||
if obs.get("occurred_end"):
|
||||
obs_data["occurred_end"] = obs["occurred_end"].isoformat()
|
||||
if obs.get("mentioned_at"):
|
||||
obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
|
||||
|
||||
# Include source memories (up to 3 for brevity)
|
||||
if obs.get("source_memories"):
|
||||
obs_data["source_memories"] = [
|
||||
{
|
||||
"text": sm["text"],
|
||||
"event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
|
||||
"occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
|
||||
}
|
||||
for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
|
||||
]
|
||||
|
||||
obs_list.append(obs_data)
|
||||
|
||||
observations_text = json.dumps(obs_list, indent=2)
|
||||
observations_text = "\n".join(
|
||||
f'- ID: {obs["id"]}, Tags: {json.dumps(obs["tags"])}, Text: "{obs["text"]}" (proof_count: {obs["proof_count"]})'
|
||||
for obs in observations
|
||||
)
|
||||
else:
|
||||
observations_text = "[]"
|
||||
observations_text = "None (this is a new topic - create if fact contains durable knowledge)"
|
||||
|
||||
# Only include mission section if mission is set and not the default
|
||||
mission_section = ""
|
||||
|
||||
@@ -47,31 +47,23 @@ CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowle
|
||||
{mission_section}
|
||||
NEW FACT: {fact_text}
|
||||
|
||||
EXISTING OBSERVATIONS (JSON array with source memories and dates):
|
||||
EXISTING OBSERVATIONS:
|
||||
{observations_text}
|
||||
|
||||
Each observation includes:
|
||||
- id: unique identifier for updating
|
||||
- text: the observation content
|
||||
- proof_count: number of supporting memories
|
||||
- tags: visibility scope (handled automatically)
|
||||
- created_at/updated_at: when observation was created/modified
|
||||
- occurred_start/occurred_end: temporal range of source facts
|
||||
- source_memories: array of supporting facts with their text and dates
|
||||
|
||||
Instructions:
|
||||
1. Extract DURABLE KNOWLEDGE from the new fact (not ephemeral state)
|
||||
2. Review source_memories in existing observations to understand evidence
|
||||
3. Check dates to detect contradictions or updates
|
||||
4. Compare with observations:
|
||||
- Same topic → UPDATE with learning_id
|
||||
- New topic → CREATE new observation
|
||||
- Purely ephemeral → return []
|
||||
1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
|
||||
2. Then compare with existing observations:
|
||||
- If an observation covers the same topic: UPDATE it with the new knowledge
|
||||
- If no observation covers the topic: CREATE a new one
|
||||
|
||||
Output JSON array of actions:
|
||||
Output JSON array of actions (ALWAYS an array, even for single action):
|
||||
[
|
||||
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
|
||||
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
|
||||
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
|
||||
]
|
||||
|
||||
Return [] if fact contains no durable knowledge."""
|
||||
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
|
||||
[]
|
||||
|
||||
If no observations exist and fact contains durable knowledge:
|
||||
[{{"action": "create", "text": "durable knowledge text", "reason": "new topic"}}]"""
|
||||
|
||||
@@ -20,7 +20,6 @@ from ..config import (
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
@@ -34,7 +33,6 @@ from ..config import (
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LITELLM_MODEL,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
@@ -101,7 +99,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
|
||||
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
@@ -110,11 +108,8 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
max_concurrent: Maximum concurrent reranking calls (default: 2).
|
||||
Higher values may cause CPU thrashing under load.
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self._model = None
|
||||
LocalSTCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@@ -144,23 +139,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
# after loading, which conflicts with accelerate's device_map handling.
|
||||
import torch
|
||||
|
||||
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
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
|
||||
else:
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
# Wrap in try-except to gracefully handle any device detection issues
|
||||
# (e.g., in CI environments or when PyTorch is built without GPU support)
|
||||
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, falling back to CPU: {e}")
|
||||
device = "cpu"
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
@@ -226,19 +211,12 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
)
|
||||
|
||||
# Determine device based on hardware availability
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
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
|
||||
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}")
|
||||
device = "cpu"
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
@@ -895,33 +873,29 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on configuration.
|
||||
Create a CrossEncoderModel instance based on environment variables.
|
||||
|
||||
Reads configuration via get_config() to ensure consistency across the codebase.
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
|
||||
Returns:
|
||||
Configured CrossEncoderModel instance
|
||||
"""
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
provider = config.reranker_provider.lower()
|
||||
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
|
||||
|
||||
if provider == "tei":
|
||||
url = config.reranker_tei_url
|
||||
url = os.environ.get(ENV_RERANKER_TEI_URL)
|
||||
if not url:
|
||||
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
|
||||
return RemoteTEICrossEncoder(
|
||||
base_url=url,
|
||||
batch_size=config.reranker_tei_batch_size,
|
||||
max_concurrent=config.reranker_tei_max_concurrent,
|
||||
)
|
||||
batch_size = int(os.environ.get(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE)))
|
||||
max_concurrent = int(os.environ.get(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT)))
|
||||
return RemoteTEICrossEncoder(base_url=url, batch_size=batch_size, max_concurrent=max_concurrent)
|
||||
elif provider == "local":
|
||||
return LocalSTCrossEncoder(
|
||||
model_name=config.reranker_local_model,
|
||||
max_concurrent=config.reranker_local_max_concurrent,
|
||||
force_cpu=config.reranker_local_force_cpu,
|
||||
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
max_concurrent = int(
|
||||
os.environ.get(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
)
|
||||
return LocalSTCrossEncoder(model_name=model_name, max_concurrent=max_concurrent)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
if not api_key:
|
||||
|
||||
@@ -18,7 +18,6 @@ import httpx
|
||||
from ..config import (
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
@@ -27,7 +26,6 @@ from ..config import (
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL,
|
||||
ENV_EMBEDDINGS_COHERE_MODEL,
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY,
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL,
|
||||
@@ -94,18 +92,15 @@ class LocalSTEmbeddings(Embeddings):
|
||||
The embedding dimension is auto-detected from the model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""
|
||||
Initialize local SentenceTransformers embeddings.
|
||||
|
||||
Args:
|
||||
model_name: Name of the SentenceTransformer model to use.
|
||||
Default: BAAI/bge-small-en-v1.5
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self._model = None
|
||||
self._dimension: int | None = None
|
||||
|
||||
@@ -139,23 +134,13 @@ class LocalSTEmbeddings(Embeddings):
|
||||
# which can cause issues when accelerate is installed but no GPU is available.
|
||||
import torch
|
||||
|
||||
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
logger.info("Embeddings: forcing CPU mode")
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
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
|
||||
else:
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
# Wrap in try-except to gracefully handle any device detection issues
|
||||
# (e.g., in CI environments or when PyTorch is built without GPU support)
|
||||
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, falling back to CPU: {e}")
|
||||
device = "cpu"
|
||||
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
@@ -214,19 +199,12 @@ class LocalSTEmbeddings(Embeddings):
|
||||
)
|
||||
|
||||
# Determine device based on hardware availability
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
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
|
||||
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}")
|
||||
device = "cpu"
|
||||
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
@@ -792,28 +770,24 @@ class LiteLLMEmbeddings(Embeddings):
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on configuration.
|
||||
Create an Embeddings instance based on environment variables.
|
||||
|
||||
Reads configuration via get_config() to ensure consistency across the codebase.
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
|
||||
Returns:
|
||||
Configured Embeddings instance
|
||||
"""
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
provider = config.embeddings_provider.lower()
|
||||
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
|
||||
|
||||
if provider == "tei":
|
||||
url = config.embeddings_tei_url
|
||||
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
|
||||
if not url:
|
||||
raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'")
|
||||
return RemoteTEIEmbeddings(base_url=url)
|
||||
elif provider == "local":
|
||||
return LocalSTEmbeddings(
|
||||
model_name=config.embeddings_local_model,
|
||||
force_cpu=config.embeddings_local_force_cpu,
|
||||
)
|
||||
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
return LocalSTEmbeddings(model_name=model_name)
|
||||
elif provider == "openai":
|
||||
# Use dedicated embeddings API key, or fall back to LLM API key
|
||||
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
|
||||
|
||||
@@ -23,17 +23,12 @@ from ..metrics import get_metrics_collector
|
||||
from .db_budget import budgeted_operation
|
||||
|
||||
# Context variable for current schema (async-safe, per-task isolation)
|
||||
# Note: default is None, actual default comes from config via get_current_schema()
|
||||
_current_schema: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_schema", default=None)
|
||||
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public")
|
||||
|
||||
|
||||
def get_current_schema() -> str:
|
||||
"""Get the current schema from context (falls back to config default)."""
|
||||
schema = _current_schema.get()
|
||||
if schema is None:
|
||||
# Fall back to configured default schema
|
||||
return get_config().database_schema
|
||||
return schema
|
||||
"""Get the current schema from context (default: 'public')."""
|
||||
return _current_schema.get()
|
||||
|
||||
|
||||
def fq_table(table_name: str) -> str:
|
||||
@@ -886,12 +881,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if not self.db_url:
|
||||
raise ValueError("Database URL is required for migrations")
|
||||
logger.info("Running database migrations...")
|
||||
# Use configured database schema for migrations (defaults to "public")
|
||||
run_migrations(self.db_url, schema=get_config().database_schema)
|
||||
run_migrations(self.db_url)
|
||||
|
||||
# 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)
|
||||
ensure_embedding_dimension(self.db_url, self.embeddings.dimension)
|
||||
|
||||
logger.info(f"Connecting to PostgreSQL at {self.db_url}")
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def tool_search_mental_models(
|
||||
next_param += 1
|
||||
|
||||
if exclude_ids:
|
||||
filters += f" AND id != ALL(${next_param}::text[])"
|
||||
filters += f" AND id != ALL(${next_param}::uuid[])"
|
||||
params.append(exclude_ids)
|
||||
next_param += 1
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Built-in tenant extension implementations."""
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
@@ -11,13 +10,11 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
|
||||
This is a simple implementation that:
|
||||
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
|
||||
2. Returns the configured schema (HINDSIGHT_API_DATABASE_SCHEMA, default 'public')
|
||||
for all authenticated requests
|
||||
2. Returns 'public' as the schema for all authenticated requests
|
||||
|
||||
Configuration:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant, implement a custom
|
||||
TenantExtension that looks up the schema based on the API key or token claims.
|
||||
@@ -30,11 +27,11 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Validate API key and return configured schema context."""
|
||||
"""Validate API key and return public schema context."""
|
||||
if context.api_key != self.expected_api_key:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name=get_config().database_schema)
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return configured schema for single-tenant setup."""
|
||||
return [Tenant(schema=get_config().database_schema)]
|
||||
"""Return public schema for single-tenant setup."""
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
@@ -140,13 +140,6 @@ 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():
|
||||
@@ -177,7 +170,6 @@ def main():
|
||||
if args.log_level != config.log_level:
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
database_schema=config.database_schema,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
@@ -198,14 +190,11 @@ def main():
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
@@ -228,7 +217,6 @@ def main():
|
||||
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,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -527,7 +527,6 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
"""Test creating encoder from environment variables."""
|
||||
import os
|
||||
|
||||
from hindsight_api.config import clear_config_cache
|
||||
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
|
||||
|
||||
with patch.dict(
|
||||
@@ -539,7 +538,6 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
"HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT": "16",
|
||||
},
|
||||
):
|
||||
clear_config_cache() # Clear cache to pick up patched env vars
|
||||
encoder = create_cross_encoder_from_env()
|
||||
|
||||
assert isinstance(encoder, RemoteTEICrossEncoder)
|
||||
@@ -547,8 +545,6 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
assert encoder.batch_size == 256
|
||||
assert encoder.max_concurrent == 16
|
||||
|
||||
clear_config_cache() # Clear cache after test
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEI Reranker Performance Benchmark Tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.0.7"
|
||||
|
||||
# import apis into sdk package
|
||||
from hindsight_client_api.api.banks_api import BanksApi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hindsight-client"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Python client for Hindsight - Semantic memory system with personality-driven thinking"
|
||||
authors = [
|
||||
{name = "Hindsight Team"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0",
|
||||
"description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking",
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-control-plane",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Control plane for Hindsight - Semantic memory system",
|
||||
"bin": {
|
||||
"hindsight-control-plane": "./bin/cli.js"
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# Consolidation Performance Benchmark
|
||||
|
||||
## Overview
|
||||
|
||||
This benchmark measures consolidation throughput (operations per second) and identifies bottlenecks in the consolidation pipeline.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run with default settings (100 memories)
|
||||
./scripts/benchmarks/run-consolidation.sh
|
||||
|
||||
# Run with custom number of memories
|
||||
NUM_MEMORIES=50 ./scripts/benchmarks/run-consolidation.sh
|
||||
|
||||
# Run with different model
|
||||
export HINDSIGHT_API_CONSOLIDATION_LLM_MODEL=llama-3.1-70b-versatile
|
||||
NUM_MEMORIES=100 ./scripts/benchmarks/run-consolidation.sh
|
||||
```
|
||||
|
||||
## What It Measures
|
||||
|
||||
The benchmark:
|
||||
1. Creates N test memories with diverse content (similar facts, contradictions, different entities)
|
||||
2. Runs consolidation and measures time spent in each component:
|
||||
- **Recall**: Finding related observations
|
||||
- **LLM**: Deciding on consolidation actions
|
||||
- **Embedding**: Generating embeddings for new/updated observations
|
||||
- **DB Write**: Writing to database
|
||||
3. Reports throughput (op/sec) and detailed timing breakdown
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Metrics
|
||||
- **Throughput (op/sec)**: Memories processed per second
|
||||
- **Timing Breakdown**: % of time spent in each component
|
||||
- **Observations Created/Updated**: Quality indicator
|
||||
|
||||
### Baseline Performance (groq/openai/gpt-oss-120b)
|
||||
- **~0.7-1.0 op/sec** (1-1.4 seconds per memory)
|
||||
- **LLM: 80-87%** of time (main bottleneck)
|
||||
- **Recall: 10-17%** of time (secondary bottleneck)
|
||||
|
||||
## Results
|
||||
|
||||
See:
|
||||
- `ANALYSIS.md` - Detailed bottleneck analysis
|
||||
- `RESULTS.md` - Performance results and recommendations
|
||||
- `benchmarks/results/` - Raw benchmark data (JSON)
|
||||
|
||||
## Optimizations
|
||||
|
||||
### Implemented
|
||||
✅ Batch database queries (fixed N+1 problem)
|
||||
✅ Reduced recall token budget (5000 → 2000)
|
||||
✅ Limited observation results (top 15)
|
||||
|
||||
### Recommended
|
||||
🔧 Use faster LLM model for consolidation
|
||||
🔧 Enable prompt caching (if available)
|
||||
🔧 Optimize prompt verbosity
|
||||
|
||||
See `RESULTS.md` for detailed recommendations.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `NUM_MEMORIES`: Number of memories to create (default: 100)
|
||||
- `HINDSIGHT_API_CONSOLIDATION_LLM_MODEL`: Model for consolidation
|
||||
- `HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER`: Provider for consolidation
|
||||
- `HINDSIGHT_API_DATABASE_URL`: Database URL
|
||||
- `HINDSIGHT_LOG_LEVEL`: Logging level (INFO for detailed logs)
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
Consolidation Benchmark Results
|
||||
┌────────────────────────────────┬─────────────┐
|
||||
│ Metric │ Value │
|
||||
├────────────────────────────────┼─────────────┤
|
||||
│ Total Time │ 60.28s │
|
||||
│ Memories Processed │ 43 │
|
||||
│ Throughput │ 0.71 op/sec │
|
||||
│ Avg Time/Memory │ 1.402s │
|
||||
│ │ │
|
||||
│ Observations Created │ 4 │
|
||||
│ Observations Updated │ 38 │
|
||||
│ Observations Merged │ 0 │
|
||||
│ Skipped (No Durable Knowledge) │ 1 │
|
||||
└────────────────────────────────┴─────────────┘
|
||||
|
||||
Timing breakdown:
|
||||
recall=6.295s (10.4%)
|
||||
llm=52.144s (86.5%) ← BOTTLENECK
|
||||
embedding=1.717s (2.8%)
|
||||
db_write=0.075s (0.1%)
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
"""Consolidation performance benchmarks."""
|
||||
@@ -1,331 +0,0 @@
|
||||
"""
|
||||
Consolidation performance benchmark.
|
||||
|
||||
Measures consolidation throughput (op/sec) and identifies bottlenecks by:
|
||||
1. Ingesting a batch of diverse memories
|
||||
2. Running consolidation manually with detailed timing
|
||||
3. Analyzing timing breakdown to identify bottlenecks
|
||||
4. Reporting op/sec and time spent in each component
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
from hindsight_api.models import RequestContext
|
||||
from rich.console import Console
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# Sample diverse memories to trigger different consolidation patterns
|
||||
SAMPLE_MEMORIES = [
|
||||
# Similar memories (should merge)
|
||||
"Alice loves coffee and drinks it every morning.",
|
||||
"Alice prefers coffee over tea for her morning beverage.",
|
||||
"Alice switched to decaf coffee recently.",
|
||||
# Different person (should NOT merge with Alice)
|
||||
"Bob works at Google as a software engineer.",
|
||||
"Bob has been at Google for 5 years.",
|
||||
# Technical facts
|
||||
"Python is a programming language used for data science.",
|
||||
"Python supports object-oriented and functional programming.",
|
||||
# Product info
|
||||
"The new iPhone 15 was released in September 2023.",
|
||||
"The iPhone 15 features USB-C charging instead of Lightning.",
|
||||
# Contradictions (should merge with conflict resolution)
|
||||
"The meeting is scheduled for Tuesday at 2pm.",
|
||||
"The meeting was moved to Wednesday at 3pm.",
|
||||
# Entity-rich content
|
||||
"Sarah Smith works at Microsoft in Seattle.",
|
||||
"Sarah graduated from Stanford University in 2015.",
|
||||
# Temporal information
|
||||
"The project started on January 15, 2024.",
|
||||
"The project deadline is March 30, 2024.",
|
||||
# Preferences
|
||||
"User prefers dark mode in applications.",
|
||||
"User uses keyboard shortcuts extensively.",
|
||||
# World knowledge
|
||||
"Paris is the capital of France.",
|
||||
"The Eiffel Tower is located in Paris.",
|
||||
# Multiple entities
|
||||
"John and Mary went to the Italian restaurant on Main Street.",
|
||||
"The Italian restaurant on Main Street has excellent pizza.",
|
||||
]
|
||||
|
||||
|
||||
async def create_test_memories(memory_engine: MemoryEngine, bank_id: str, num_memories: int = 100) -> None:
|
||||
"""
|
||||
Create test memories by repeating and varying the sample memories.
|
||||
|
||||
Args:
|
||||
memory_engine: MemoryEngine instance
|
||||
bank_id: Bank ID to ingest into
|
||||
num_memories: Number of memories to create
|
||||
"""
|
||||
console.print(f"\n[cyan]Creating {num_memories} test memories...[/cyan]")
|
||||
|
||||
# Generate memories by cycling through samples
|
||||
memories = []
|
||||
for i in range(num_memories):
|
||||
base_memory = SAMPLE_MEMORIES[i % len(SAMPLE_MEMORIES)]
|
||||
# Add variation to avoid exact duplicates
|
||||
memory = f"{base_memory} (context: test {i + 1})"
|
||||
memories.append(
|
||||
{
|
||||
"content": memory,
|
||||
"context": f"Test memory {i + 1}",
|
||||
}
|
||||
)
|
||||
|
||||
# Batch ingest
|
||||
console.print("[yellow]Ingesting memories in batch...[/yellow]")
|
||||
start_time = time.time()
|
||||
await memory_engine.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=memories,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
ingest_time = time.time() - start_time
|
||||
console.print(
|
||||
f"[green]✓[/green] Ingested {num_memories} memories in {ingest_time:.2f}s ({num_memories / ingest_time:.2f} mem/sec)"
|
||||
)
|
||||
|
||||
|
||||
async def run_consolidation_benchmark(
|
||||
memory_engine: MemoryEngine,
|
||||
bank_id: str,
|
||||
enable_detailed_logs: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run consolidation and measure performance.
|
||||
|
||||
Args:
|
||||
memory_engine: MemoryEngine instance
|
||||
bank_id: Bank ID to consolidate
|
||||
enable_detailed_logs: Enable detailed consolidation logs
|
||||
|
||||
Returns:
|
||||
Performance metrics dict
|
||||
"""
|
||||
console.print("\n[cyan]Running consolidation benchmark...[/cyan]")
|
||||
|
||||
# Set log level to INFO to see consolidation logs
|
||||
if enable_detailed_logs:
|
||||
# Configure logging for consolidation
|
||||
consolidation_logger = logging.getLogger("hindsight_api.engine.consolidation.consolidator")
|
||||
consolidation_logger.setLevel(logging.INFO)
|
||||
|
||||
# Add console handler if not present
|
||||
if not consolidation_logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
consolidation_logger.addHandler(handler)
|
||||
|
||||
console.print("[yellow]Detailed logging enabled for consolidation[/yellow]")
|
||||
|
||||
# Run consolidation and measure time
|
||||
start_time = time.time()
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
total_time = time.time() - start_time
|
||||
|
||||
# Calculate op/sec
|
||||
memories_processed = result.get("memories_processed", 0)
|
||||
ops_per_sec = memories_processed / total_time if total_time > 0 else 0
|
||||
|
||||
console.print("\n[green]✓[/green] Consolidation complete!")
|
||||
console.print(f" Total time: {total_time:.2f}s")
|
||||
console.print(f" Memories processed: {memories_processed}")
|
||||
console.print(f" Throughput: {ops_per_sec:.2f} op/sec")
|
||||
console.print(f" Avg time per memory: {total_time / memories_processed:.3f}s" if memories_processed > 0 else "")
|
||||
|
||||
return {
|
||||
"total_time": total_time,
|
||||
"memories_processed": memories_processed,
|
||||
"ops_per_sec": ops_per_sec,
|
||||
"consolidation_result": result,
|
||||
}
|
||||
|
||||
|
||||
async def analyze_timing_breakdown(bank_id: str) -> None:
|
||||
"""
|
||||
Analyze the timing breakdown from consolidation logs.
|
||||
|
||||
NOTE: This relies on the performance logging in ConsolidationPerfLog.
|
||||
The logs will show timing breakdowns for: recall, llm, embedding, db_write
|
||||
"""
|
||||
console.print("\n[cyan]Timing Breakdown Analysis:[/cyan]")
|
||||
console.print("Check the logs above for detailed timing breakdown:")
|
||||
console.print(" - recall: Time spent finding related observations")
|
||||
console.print(" - llm: Time spent in LLM calls for consolidation decisions")
|
||||
console.print(" - embedding: Time spent generating embeddings")
|
||||
console.print(" - db_write: Time spent writing to database")
|
||||
|
||||
|
||||
async def get_bank_stats(memory_engine: MemoryEngine, bank_id: str) -> dict[str, Any]:
|
||||
"""Get memory statistics for the bank."""
|
||||
pool = await memory_engine._get_pool()
|
||||
from hindsight_api.engine.memory_engine import fq_table
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Count memories by fact type
|
||||
stats = await conn.fetch(
|
||||
f"""
|
||||
SELECT fact_type, COUNT(*) as count
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
GROUP BY fact_type
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
return {row["fact_type"]: row["count"] for row in stats}
|
||||
|
||||
|
||||
def display_results_table(metrics: dict[str, Any], stats_before: dict, stats_after: dict) -> None:
|
||||
"""Display benchmark results in a formatted table."""
|
||||
table = Table(title="Consolidation Benchmark Results")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("Total Time", f"{metrics['total_time']:.2f}s")
|
||||
table.add_row("Memories Processed", str(metrics["memories_processed"]))
|
||||
table.add_row("Throughput", f"{metrics['ops_per_sec']:.2f} op/sec")
|
||||
table.add_row(
|
||||
"Avg Time/Memory",
|
||||
f"{metrics['total_time'] / metrics['memories_processed']:.3f}s" if metrics["memories_processed"] > 0 else "N/A",
|
||||
)
|
||||
|
||||
result = metrics["consolidation_result"]
|
||||
table.add_row("", "") # Separator
|
||||
table.add_row("Observations Created", str(result.get("observations_created", 0)))
|
||||
table.add_row("Observations Updated", str(result.get("observations_updated", 0)))
|
||||
table.add_row("Observations Merged", str(result.get("observations_merged", 0)))
|
||||
table.add_row("Skipped (No Durable Knowledge)", str(result.get("skipped", 0)))
|
||||
|
||||
table.add_row("", "") # Separator
|
||||
table.add_row("Memories Before", str(stats_before.get("experience", 0) + stats_before.get("world", 0)))
|
||||
table.add_row("Observations After", str(stats_after.get("observation", 0)))
|
||||
|
||||
console.print("\n")
|
||||
console.print(table)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the consolidation benchmark."""
|
||||
console.print("\n[bold cyan]Consolidation Performance Benchmark[/bold cyan]")
|
||||
console.print("=" * 80)
|
||||
|
||||
# Configuration
|
||||
num_memories = int(os.getenv("NUM_MEMORIES", "100"))
|
||||
bank_id = f"consolidation-bench-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
console.print("\n[cyan]Configuration:[/cyan]")
|
||||
console.print(f" Bank ID: {bank_id}")
|
||||
console.print(f" Number of memories: {num_memories}")
|
||||
console.print(f" LLM Provider: {os.getenv('HINDSIGHT_API_LLM_PROVIDER', 'not set')}")
|
||||
console.print(f" LLM Model: {os.getenv('HINDSIGHT_API_LLM_MODEL', 'not set')}")
|
||||
|
||||
# Check if consolidation is enabled
|
||||
config = get_config()
|
||||
if not config.enable_observations:
|
||||
console.print("\n[red]ERROR: Consolidation is disabled (enable_observations=False)[/red]")
|
||||
console.print("Set HINDSIGHT_API_ENABLE_OBSERVATIONS=true to enable consolidation")
|
||||
return
|
||||
|
||||
# Initialize memory engine
|
||||
console.print("\n[1] Initializing memory engine...")
|
||||
memory = MemoryEngine(
|
||||
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0"),
|
||||
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
|
||||
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
|
||||
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"),
|
||||
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
|
||||
)
|
||||
await memory.initialize()
|
||||
console.print("[green]✓[/green] Memory engine initialized")
|
||||
|
||||
try:
|
||||
# Create bank
|
||||
console.print("\n[2] Creating test bank...")
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext())
|
||||
console.print(f"[green]✓[/green] Created bank: {bank_id}")
|
||||
|
||||
# Get initial stats
|
||||
stats_before = await get_bank_stats(memory, bank_id)
|
||||
|
||||
# Create test memories
|
||||
console.print("\n[3] Creating test memories...")
|
||||
await create_test_memories(memory, bank_id, num_memories)
|
||||
|
||||
# Run consolidation benchmark
|
||||
console.print("\n[4] Running consolidation benchmark...")
|
||||
metrics = await run_consolidation_benchmark(memory, bank_id, enable_detailed_logs=True)
|
||||
|
||||
# Get final stats
|
||||
stats_after = await get_bank_stats(memory, bank_id)
|
||||
|
||||
# Analyze timing
|
||||
console.print("\n[5] Analyzing performance...")
|
||||
await analyze_timing_breakdown(bank_id)
|
||||
|
||||
# Display results
|
||||
console.print("\n[6] Results:")
|
||||
display_results_table(metrics, stats_before, stats_after)
|
||||
|
||||
# Save results to file
|
||||
output_dir = Path("benchmarks/results")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_file = output_dir / f"consolidation_benchmark_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
|
||||
results = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"config": {
|
||||
"num_memories": num_memories,
|
||||
"bank_id": bank_id,
|
||||
"llm_provider": os.getenv("HINDSIGHT_API_LLM_PROVIDER"),
|
||||
"llm_model": os.getenv("HINDSIGHT_API_LLM_MODEL"),
|
||||
},
|
||||
"metrics": metrics,
|
||||
"stats_before": stats_before,
|
||||
"stats_after": stats_after,
|
||||
}
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
|
||||
console.print(f"\n[green]✓[/green] Results saved to: {output_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
console.print("\n[7] Cleaning up...")
|
||||
await memory.delete_bank(bank_id, request_context=RequestContext())
|
||||
console.print(f"[green]✓[/green] Deleted bank: {bank_id}")
|
||||
|
||||
# Close memory engine connections
|
||||
pool = await memory._get_pool()
|
||||
await pool.close()
|
||||
console.print("[green]✓[/green] Memory engine connections closed")
|
||||
|
||||
console.print("\n[bold green]✓ Benchmark Complete![/bold green]\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-dev"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Development utilities for Hindsight"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -20,24 +20,10 @@ The API service handles all memory operations (retain, recall, reflect).
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
| `HINDSIGHT_API_DATABASE_SCHEMA` | PostgreSQL schema name for tables | `public` |
|
||||
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` |
|
||||
|
||||
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
|
||||
|
||||
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
|
||||
- Multi-database setups where you want Hindsight tables in a dedicated schema
|
||||
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
|
||||
- Organizational preferences for schema naming conventions
|
||||
|
||||
```bash
|
||||
# Example: Using a custom schema
|
||||
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||
export HINDSIGHT_API_DATABASE_SCHEMA=hindsight
|
||||
```
|
||||
|
||||
Migrations will automatically create the schema if it doesn't exist and create all tables in the configured schema.
|
||||
|
||||
### Database Connection Pool
|
||||
|
||||
| Variable | Description | Default |
|
||||
@@ -378,7 +364,6 @@ Observations are consolidated knowledge synthesized from facts.
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
|
||||
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
|
||||
| `HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC` | Run observation generation asynchronously (after retain completes) | `false` |
|
||||
|
||||
### Reflect
|
||||
@@ -454,7 +439,6 @@ export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
|
||||
```bash
|
||||
# API Service
|
||||
HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:hindsight_dev@localhost:5432/hindsight
|
||||
# HINDSIGHT_API_DATABASE_SCHEMA=public # optional, defaults to 'public'
|
||||
HINDSIGHT_API_LLM_PROVIDER=groq
|
||||
HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"build": "INCLUDE_CURRENT_VERSION=true docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 961 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB |
@@ -1,3 +1,3 @@
|
||||
"""Hindsight embedded CLI - local memory operations without a server."""
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-embed"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Hindsight embedded CLI - local memory operations without a server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hindsight-litellm"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Universal LLM memory integration via LiteLLM - works with 100+ providers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Generated
+2
-2
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"hindsight-clients/typescript": {
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.0",
|
||||
@@ -131,7 +131,7 @@
|
||||
},
|
||||
"hindsight-control-plane": {
|
||||
"name": "@vectorize-io/hindsight-control-plane",
|
||||
"version": "0.4.0",
|
||||
"version": "0.3.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Consolidation Performance Benchmark Runner
|
||||
# Measures consolidation throughput (op/sec) and identifies bottlenecks
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Source .env if it exists
|
||||
if [ -f "$REPO_ROOT/.env" ]; then
|
||||
source "$REPO_ROOT/.env"
|
||||
echo "Loaded environment from .env"
|
||||
fi
|
||||
|
||||
# Default configuration
|
||||
NUM_MEMORIES="${NUM_MEMORIES:-100}"
|
||||
|
||||
# Enable observations (required for consolidation)
|
||||
export HINDSIGHT_API_ENABLE_OBSERVATIONS=true
|
||||
|
||||
echo "Running consolidation benchmark with configuration:"
|
||||
echo " NUM_MEMORIES=$NUM_MEMORIES"
|
||||
echo " HINDSIGHT_API_LLM_PROVIDER=${HINDSIGHT_API_LLM_PROVIDER:-not set}"
|
||||
echo " HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-not set}"
|
||||
echo ""
|
||||
|
||||
# Run benchmark
|
||||
cd "$REPO_ROOT"
|
||||
uv run python -m benchmarks.consolidation.consolidation_benchmark
|
||||
|
||||
echo ""
|
||||
echo "Benchmark complete! Check benchmarks/results/ for detailed results."
|
||||
@@ -1295,7 +1295,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-all"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
source = { editable = "hindsight" }
|
||||
dependencies = [
|
||||
{ name = "hindsight-api" },
|
||||
@@ -1319,7 +1319,7 @@ provides-extras = ["test"]
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
source = { editable = "hindsight-api" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1447,7 +1447,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-client"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
source = { editable = "hindsight-clients/python" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1481,7 +1481,7 @@ provides-extras = ["test"]
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-dev"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
source = { editable = "hindsight-dev" }
|
||||
dependencies = [
|
||||
{ name = "hindsight-api" },
|
||||
@@ -1527,7 +1527,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-embed"
|
||||
version = "0.4.1"
|
||||
version = "0.4.0"
|
||||
source = { editable = "hindsight-embed" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
Reference in New Issue
Block a user