brandind and misc fixes

This commit is contained in:
Nicolò Boschi
2025-12-11 12:46:48 +01:00
parent f813a807e7
commit fa554b8980
65 changed files with 3732 additions and 3246 deletions
+4
View File
@@ -145,3 +145,7 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved
- PostgreSQL with pgvector extension
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
# Branding
## Colors
- Primary: gradient from #0074d9 to #009296
+9 -21
View File
@@ -4,10 +4,11 @@
[Documentation](https://vectorize-io.github.io/hindsight) • [Paper](#coming-soon) • [Examples](https://github.com/vectorize-io/hindsight-cookbook)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI - hindsight-api](https://img.shields.io/pypi/v/hindsight-api?label=hindsight-api)](https://pypi.org/project/hindsight-api/)
[![PyPI - hindsight-client](https://img.shields.io/pypi/v/hindsight-client?label=hindsight-client)](https://pypi.org/project/hindsight-client/)
[![npm](https://img.shields.io/npm/v/@vectorize-io/hindsight-client)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![npm - @vectorize-io/hindsight-client](https://img.shields.io/npm/v/@vectorize-io/hindsight-client)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
@@ -53,12 +54,10 @@ Memories in Hindsight are stored in banks (e.g. memory banks). When memories are
```bash
export OPENAI_API_KEY=your-key
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
ghcr.io/vectorize-io/hindsight:latest
```
API: http://localhost:8888
@@ -208,29 +207,18 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
## Integrations
### Examples
[Examples Repo]([./examples](https://github.com/vectorize-io/hindsight-cookbook)) includes:
- Basic usage
- Multi-session conversations
- Temporal queries
- Entity reasoning
- Opinion tracking
- Production setup (Docker Compose + monitoring)
---
## Resources
**Documentation:** [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
**Documentation:**
- [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
**Clients:**
- [Python](http://hindsight.vectorize.io/sdks/python)
- [Node.js](http://hindsight.vectorize.io/sdks/nodejs)
- [REST API](http://hindsight.vectorize.io/api-reference)
- [REST API](https://hindsight.vectorize.io/api-reference)
- [CLI](https://hindsight.vectorize.io/sdks/cli)
**Community:**
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
+2 -7
View File
@@ -1,9 +1,6 @@
#!/bin/bash
set -e
echo "🚀 Starting Hindsight..."
echo ""
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
@@ -31,16 +28,14 @@ if [ "$ENABLE_API" = "true" ]; then
PIDS+=($API_PID)
# Wait for API to be ready
echo "⏳ Waiting for API..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null; then
echo "✅ API is ready"
break
fi
sleep 1
done
else
echo "⏭️ API disabled (HINDSIGHT_ENABLE_API=false)"
echo "API disabled (HINDSIGHT_ENABLE_API=false)"
fi
# Start Control Plane if enabled
@@ -51,7 +46,7 @@ if [ "$ENABLE_CP" = "true" ]; then
CP_PID=$!
PIDS+=($CP_PID)
else
echo "⏭️ Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
echo "Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
fi
# Print status
+30 -1
View File
@@ -672,11 +672,15 @@ class DeleteResponse(BaseModel):
"""Response model for delete operations."""
model_config = ConfigDict(json_schema_extra={
"example": {
"success": True
"success": True,
"message": "Deleted successfully",
"deleted_count": 10
}
})
success: bool
message: Optional[str] = None
deleted_count: Optional[int] = None
def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
@@ -1696,6 +1700,31 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}",
response_model=DeleteResponse,
summary="Delete memory bank",
description="Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. "
"This is a destructive operation that cannot be undone.",
operation_id="delete_bank",
tags=["Banks"]
)
async def api_delete_bank(bank_id: str):
"""Delete an entire memory bank and all its data."""
try:
result = await app.state.memory.delete_bank(bank_id)
return DeleteResponse(
success=True,
message=f"Bank '{bank_id}' and all associated data deleted successfully",
deleted_count=result.get("memory_units_deleted", 0) + result.get("entities_deleted", 0) + result.get("documents_deleted", 0)
)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
+89
View File
@@ -0,0 +1,89 @@
"""
Banner display for Hindsight API startup.
Shows the logo and tagline with gradient colors.
"""
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
# Pre-generated logo (generated by test-logo.py)
LOGO = """\
\033[38;2;9;127;184m\u2584\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m\u2584\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m\u2584\033[0m\033[38;2;7;140;156m\u2584\033[0m
\033[38;2;8;125;192m\u2584\033[0m \033[38;2;3;132;191m\u2580\033[0m\033[38;2;2;133;192m\u2584\033[0m \033[38;2;3;132;180m\u2584\033[0m\033[38;2;1;137;184m\u2584\033[0m\033[38;2;3;133;174m\u2584\033[0m \033[38;2;3;142;176m\u2584\033[0m\033[38;2;4;142;169m\u2580\033[0m \033[38;2;10;144;164m\u2584\033[0m
\033[38;2;6;121;195m\u2580\033[0m\033[38;2;5;128;203m\u2580\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m\u2584\033[0m\033[38;2;2;126;196m\u2584\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m\u2584\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m\u2584\033[0m\033[38;2;1;141;196m\u2580\033[0m\033[38;2;1;135;183m\u2580\033[0m\033[38;2;1;148;198m\u2580\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m\u2584\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m\u2584\033[0m\033[38;2;3;138;173m\u2584\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m\u2584\033[0m\033[38;2;7;144;169m\u2580\033[0m\033[38;2;7;139;158m\u2580\033[0m
\033[48;2;2;128;202m\033[38;2;2;124;201m\u2584\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m\u2584\033[0m\033[38;2;2;128;196m\u2584\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m\u2584\033[0m \033[38;2;1;135;186m\u2584\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m\u2584\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m\u2584\033[0m
\033[48;2;8;118;200m\033[38;2;8;121;209m\u2584\033[0m\033[38;2;3;121;203m\u2580\033[0m \033[38;2;3;122;192m\u2580\033[0m\033[38;2;1;138;216m\u2580\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m\u2584\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m\u2584\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m\u2584\033[0m\033[38;2;1;140;196m\u2580\033[0m \033[38;2;4;134;175m\u2580\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m\u2584\033[0m """
def _interpolate_color(start: tuple, end: tuple, t: float) -> tuple:
"""Interpolate between two RGB colors."""
return (
int(start[0] + (end[0] - start[0]) * t),
int(start[1] + (end[1] - start[1]) * t),
int(start[2] + (end[2] - start[2]) * t),
)
def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIENT_END) -> str:
"""Render text with a gradient color effect."""
result = []
length = len(text)
for i, char in enumerate(text):
if char == ' ':
result.append(' ')
else:
t = i / max(length - 1, 1)
r, g, b = _interpolate_color(start, end, t)
result.append(f"\033[38;2;{r};{g};{b}m{char}")
result.append("\033[0m")
return "".join(result)
def print_banner():
"""Print the Hindsight startup banner."""
print(LOGO)
tagline = gradient_text("Hindsight: Agent Memory That Works Like Human Memory")
print(f"\n {tagline}\n")
def color(text: str, t: float = 0.0) -> str:
"""Color text using gradient position (0.0 = start, 1.0 = end)."""
r, g, b = _interpolate_color(GRADIENT_START, GRADIENT_END, t)
return f"\033[38;2;{r};{g};{b}m{text}\033[0m"
def color_start(text: str) -> str:
"""Color text with gradient start color (#0074d9)."""
return color(text, 0.0)
def color_end(text: str) -> str:
"""Color text with gradient end color (#009296)."""
return color(text, 1.0)
def color_mid(text: str) -> str:
"""Color text with gradient middle color."""
return color(text, 0.5)
def dim(text: str) -> str:
"""Dim/gray text."""
return f"\033[38;2;128;128;128m{text}\033[0m"
def print_startup_info(host: str, port: int, database_url: str, llm_provider: str,
llm_model: str, embeddings_provider: str, reranker_provider: str,
mcp_enabled: bool = False):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
if mcp_enabled:
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
print()
+2 -2
View File
@@ -32,8 +32,8 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "groq"
DEFAULT_LLM_MODEL = "openai/gpt-oss-20b"
DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
@@ -91,12 +91,35 @@ class LLMProvider:
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
self._gemini_client = None
else:
self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, max_retries=0)
# Only pass base_url if it's set (OpenAI uses default URL otherwise)
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = AsyncOpenAI(**client_kwargs)
self._gemini_client = None
logger.info(
f"Initialized LLM: provider={self.provider}, model={self.model}, base_url={self.base_url}"
)
async def verify_connection(self) -> None:
"""
Verify that the LLM provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
)
# If we get here without exception, the connection is working
logger.info(f"LLM verified: {self.provider}/{self.model}")
except Exception as e:
raise RuntimeError(
f"LLM connection verification failed for {self.provider}/{self.model}: {e}"
) from e
async def call(
self,
@@ -149,7 +172,12 @@ class LLMProvider:
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
# Check if model supports reasoning parameter (o1, o3, gpt-5 families)
model_lower = self.model.lower()
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3"])
# GPT-5/o1/o3 family doesn't support custom temperature (only default 1)
if temperature is not None and not is_reasoning_model:
call_params["temperature"] = temperature
# Provider-specific parameters
@@ -216,7 +244,8 @@ class LLMProvider:
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1})")
status_code = getattr(e, 'status_code', None) or getattr(getattr(e, 'response', None), 'status_code', None)
logger.warning(f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
@@ -453,12 +453,17 @@ class MemoryEngine:
# Query analyzer load is sync and CPU-bound
await loop.run_in_executor(None, self.query_analyzer.load)
async def verify_llm():
"""Verify LLM connection is working."""
await self._llm_config.verify_connection()
# Run pg0 and all model initializations in parallel
await asyncio.gather(
start_pg0(),
init_embeddings(),
init_cross_encoder(),
init_query_analyzer(),
verify_llm(),
)
# Run database migrations if enabled
@@ -1791,10 +1796,14 @@ class MemoryEngine:
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
# Delete the bank profile itself
await conn.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
return {
"memory_units_deleted": units_count,
"entities_deleted": entities_count,
"documents_deleted": documents_count
"documents_deleted": documents_count,
"bank_deleted": True
}
except Exception as e:
@@ -1839,10 +1848,11 @@ class MemoryEngine:
""", *query_params)
# Get links, filtering to only include links between units of the selected agent
# Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links
unit_ids = [row['id'] for row in units]
if unit_ids:
links = await conn.fetch("""
SELECT
SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid))
ml.from_unit_id,
ml.to_unit_id,
ml.link_type,
@@ -1851,7 +1861,7 @@ class MemoryEngine:
FROM memory_links ml
LEFT JOIN entities e ON ml.entity_id = e.id
WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[])
ORDER BY ml.link_type, ml.weight DESC
ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC
""", unit_ids)
else:
links = []
@@ -390,6 +390,27 @@ async def create_temporal_links_batch_per_fact(
# Filter and create links in memory (much faster than N queries)
link_gen_start = time_mod.time()
links = compute_temporal_links(new_units, all_candidates, time_window_hours)
# Also compute temporal links WITHIN the new batch (new units to each other)
if len(new_units) > 1:
# Convert new_units dict to candidate format for within-batch linking
new_unit_items = list(new_units.items())
for i, (unit_id, event_date) in enumerate(new_unit_items):
unit_event_date_norm = _normalize_datetime(event_date)
# Compare with other new units (only those after this one to avoid duplicates)
for j in range(i + 1, len(new_unit_items)):
other_id, other_event_date = new_unit_items[j]
other_event_date_norm = _normalize_datetime(other_event_date)
# Check if within time window
time_diff_hours = abs((unit_event_date_norm - other_event_date_norm).total_seconds() / 3600)
if time_diff_hours <= time_window_hours:
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
# Create bidirectional links
links.append((unit_id, other_id, 'temporal', weight, None))
links.append((other_id, unit_id, 'temporal', weight, None))
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links:
@@ -514,9 +535,38 @@ async def create_semantic_links_batch(
for idx in sorted_indices:
similar_id = existing_ids[idx]
similarity = float(similarities[idx])
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[idx])))
all_links.append((unit_id, similar_id, 'semantic', similarity, None))
# Also compute similarities WITHIN the new batch (new units to each other)
# Apply the same top_k limit per unit as we do for existing units
if len(unit_ids) > 1:
new_embeddings_matrix = np.array(embeddings)
for i, unit_id in enumerate(unit_ids):
# Compute similarities with all OTHER new units
other_indices = [j for j in range(len(unit_ids)) if j != i]
if not other_indices:
continue
other_embeddings = new_embeddings_matrix[other_indices]
similarities = np.dot(other_embeddings, new_embeddings_matrix[i])
# Find top-k above threshold (same logic as existing units)
above_threshold = np.where(similarities >= threshold)[0]
if len(above_threshold) > 0:
# Sort by similarity (descending) and take top-k
sorted_local_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k]
for local_idx in sorted_local_indices:
other_idx = other_indices[local_idx]
other_id = unit_ids[other_idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[local_idx])))
all_links.append((unit_id, other_id, 'semantic', similarity, None))
_log(log_buffer, f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s")
if all_links:
+17 -9
View File
@@ -21,6 +21,10 @@ from . import MemoryEngine
from .api import create_app
from .config import get_config, HindsightConfig
from .banner import print_banner
print()
print_banner()
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
@@ -184,15 +188,19 @@ def main():
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
print(f"\nStarting Hindsight API...")
print(f" URL: http://{args.host}:{args.port}")
print(f" Database: {config.database_url}")
print(f" LLM: {config.llm_provider} / {config.llm_model}")
print(f" Embeddings: {config.embeddings_provider}")
print(f" Reranker: {config.reranker_provider}")
if config.mcp_enabled:
print(f" MCP: enabled at /mcp")
print()
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
uvicorn.run(**uvicorn_config)
+8 -7
View File
@@ -257,16 +257,17 @@ class EmbeddedPostgres:
last_error = stderr or f"pg0 start returned exit code {returncode}"
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.info(f"Retrying in {delay:.1f}s...")
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.debug(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
# All retries exhausted - use constructed URI as fallback
uri = f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
logger.warning(f"All pg0 start attempts failed, using constructed URI: {uri}")
return uri
# All retries exhausted - fail
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. "
f"Last error: {last_error.strip() if last_error else 'unknown'}"
)
async def stop(self) -> None:
"""Stop the PostgreSQL server."""
+131
View File
@@ -0,0 +1,131 @@
"""
Test LLM provider with different models and providers.
"""
import os
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
# Model matrix: (provider, model)
MODEL_MATRIX = [
# OpenAI models
("openai", "gpt-4o-mini"),
("openai", "gpt-5-mini"),
# Groq models
("groq", "llama-3.3-70b-versatile"),
("groq", "openai/gpt-oss-120b"),
# Gemini models
("gemini", "gemini-2.0-flash"),
("gemini", "gemini-2.5-flash-preview-05-20"),
]
def get_api_key_for_provider(provider: str) -> str | None:
"""Get API key for provider from environment variables."""
# Try provider-specific env vars first
provider_key_map = {
"openai": ["OPENAI_API_KEY", "HINDSIGHT_API_LLM_API_KEY"],
"groq": ["GROQ_API_KEY", "HINDSIGHT_API_LLM_API_KEY"],
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY", "HINDSIGHT_API_LLM_API_KEY"],
}
for env_var in provider_key_map.get(provider, []):
key = os.getenv(env_var)
if key:
# For HINDSIGHT_API_LLM_API_KEY, only use if provider matches
if env_var == "HINDSIGHT_API_LLM_API_KEY":
configured_provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "").lower()
if configured_provider == provider:
return key
else:
return key
return None
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
async def test_llm_provider_call(provider: str, model: str):
"""
Test LLM provider can make a basic call with different models.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
# Test basic call
response = await llm.call(
messages=[{"role": "user", "content": "Say 'hello' and nothing else."}],
max_completion_tokens=50,
temperature=0.1,
)
print(f"\n{provider}/{model} response: {response}")
assert response is not None, f"{provider}/{model} returned None"
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
async def test_llm_provider_verify_connection(provider: str, model: str):
"""
Test LLM provider verify_connection method with different models.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
# Test verify_connection
await llm.verify_connection()
print(f"\n{provider}/{model} connection verified")
# Models that support large output (65000+ tokens)
LARGE_OUTPUT_MODELS = [
("openai", "gpt-5-mini"),
("gemini", "gemini-2.0-flash"),
("gemini", "gemini-2.5-flash-preview-05-20"),
]
@pytest.mark.parametrize("provider,model", LARGE_OUTPUT_MODELS)
@pytest.mark.asyncio
async def test_llm_provider_large_output(provider: str, model: str):
"""
Test LLM provider with large max_completion_tokens (65000).
Only tests models that support large outputs.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
# Test call with large max_completion_tokens
response = await llm.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=65000,
)
print(f"\n{provider}/{model} large output response: {response}")
assert response is not None, f"{provider}/{model} returned None"
+131 -1
View File
@@ -3,7 +3,7 @@ Test retain function and chunk storage.
"""
import pytest
import logging
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@@ -1595,3 +1595,133 @@ async def test_all_link_types_together(memory):
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_semantic_links_within_same_batch(memory):
"""
Test that semantic links are created between facts retained in the SAME batch.
This is a regression test - semantic links should connect similar facts
even when they are retained together in a single call.
"""
bank_id = f"test_semantic_batch_{datetime.now(timezone.utc).timestamp()}"
try:
# Retain multiple semantically similar facts in ONE batch
contents = [
{"content": "Alice is an expert in Python programming and machine learning.", "context": "team skills"},
{"content": "Bob specializes in Python development and data science.", "context": "team skills"},
{"content": "Charlie works with Python for backend API development.", "context": "team skills"},
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
)
# Flatten the list of lists
unit_ids = [uid for sublist in result for uid in sublist]
assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}"
logger.info(f"Created {len(unit_ids)} facts in single batch")
# Query semantic links between these units
async with memory._pool.acquire() as conn:
semantic_links = await conn.fetch(
"""
SELECT from_unit_id, to_unit_id, weight
FROM memory_links
WHERE from_unit_id::text = ANY($1)
AND to_unit_id::text = ANY($1)
AND link_type = 'semantic'
""",
unit_ids
)
logger.info(f"Found {len(semantic_links)} semantic links within the batch")
# All three facts mention Python - they should be linked to each other
assert len(semantic_links) > 0, (
"REGRESSION: Semantic links should be created between similar facts "
"retained in the same batch, but none were found"
)
# Log the links for debugging
for link in semantic_links:
logger.info(f" Semantic link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_temporal_links_within_same_batch(memory):
"""
Test that temporal links are created between facts retained in the SAME batch.
This is a regression test - temporal links should connect facts with nearby
event dates even when they are retained together in a single call.
"""
bank_id = f"test_temporal_batch_{datetime.now(timezone.utc).timestamp()}"
try:
# Retain multiple facts with nearby timestamps in ONE batch
base_date = datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
contents = [
{
"content": "Morning standup: Alice presented the sprint goals.",
"context": "daily meeting",
"event_date": base_date
},
{
"content": "Bob demoed the new feature after standup.",
"context": "daily meeting",
"event_date": base_date + timedelta(hours=1) # 1 hour later
},
{
"content": "Charlie reviewed the pull requests in the afternoon.",
"context": "daily meeting",
"event_date": base_date + timedelta(hours=4) # 4 hours later
},
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
)
# Flatten the list of lists
unit_ids = [uid for sublist in result for uid in sublist]
assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}"
logger.info(f"Created {len(unit_ids)} facts in single batch")
# Query temporal links between these units
async with memory._pool.acquire() as conn:
temporal_links = await conn.fetch(
"""
SELECT from_unit_id, to_unit_id, weight
FROM memory_links
WHERE from_unit_id::text = ANY($1)
AND to_unit_id::text = ANY($1)
AND link_type = 'temporal'
""",
unit_ids
)
logger.info(f"Found {len(temporal_links)} temporal links within the batch")
# All three facts are within 24 hours - they should be linked to each other
assert len(temporal_links) > 0, (
"REGRESSION: Temporal links should be created between facts with nearby dates "
"retained in the same batch, but none were found"
)
# Log the links for debugging
for link in temporal_links:
logger.info(f" Temporal link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id)
+3
View File
@@ -20,6 +20,9 @@ clap = { version = "4.5", features = ["derive", "env"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP client (for timeout configuration)
reqwest = "0.12"
# Serialization (for config and output formatting)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+16 -3
View File
@@ -13,7 +13,7 @@ use std::collections::HashMap;
// Types not defined in OpenAPI spec (TODO: add to openapi.json)
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentStats {
pub agent_id: String,
pub bank_id: String,
pub total_nodes: i32,
pub total_links: i32,
pub total_documents: i32,
@@ -38,7 +38,7 @@ pub struct Operation {
#[derive(Debug, Serialize, Deserialize)]
pub struct OperationsResponse {
pub agent_id: String,
pub bank_id: String,
pub operations: Vec<Operation>,
}
@@ -66,7 +66,13 @@ pub struct ApiClient {
impl ApiClient {
pub fn new(base_url: String) -> Result<Self> {
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
let client = AsyncClient::new(&base_url);
// Create HTTP client with 2-minute timeout
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let client = AsyncClient::new_with_client(&base_url, http_client);
Ok(ApiClient { client, runtime })
}
@@ -231,6 +237,13 @@ impl ApiClient {
Ok(response.into_inner())
})
}
pub fn delete_bank(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.delete_bank(bank_id).await?;
Ok(response.into_inner())
})
}
}
// Re-export types from the generated client for use in commands
+90 -59
View File
@@ -12,8 +12,8 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
let response = client.list_agents(verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -36,23 +36,23 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
}
}
pub fn profile(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching profile..."))
Some(ui::create_spinner("Fetching disposition..."))
} else {
None
};
let response = client.get_profile(bank_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(profile) => {
if output_format == OutputFormat::Pretty {
ui::print_profile(&profile);
ui::print_disposition(&profile);
} else {
output::print_output(&profile, output_format)?;
}
@@ -71,92 +71,69 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
let response = client.get_stats(bank_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(stats) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Statistics for bank '{}'", bank_id));
ui::print_section_header(&format!("Statistics: {}", bank_id));
println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string()));
println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string()));
println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string()));
println!();
println!(" 📊 Overview");
println!(" Total Memory Units: {}", stats.total_nodes);
println!(" Total Links: {}", stats.total_links);
println!(" Total Documents: {}", stats.total_documents);
println!();
println!(" 🧠 Memory Units by Type");
println!("{}", ui::gradient_text("─── Memory Units by Type ───"));
let mut fact_types: Vec<_> = stats.nodes_by_fact_type.iter().collect();
fact_types.sort_by_key(|(k, _)| *k);
for (fact_type, count) in fact_types {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {:<10} {}", icon, fact_type, count);
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
let t = i as f32 / fact_types.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
}
println!();
println!(" 🔗 Links by Type");
println!("{}", ui::gradient_text("─── Links by Type ───"));
let mut link_types: Vec<_> = stats.links_by_link_type.iter().collect();
link_types.sort_by_key(|(k, _)| *k);
for (link_type, count) in link_types {
let icon = match link_type.as_str() {
"temporal" => "",
"semantic" => "🔤",
"entity" => "🏷️",
_ => ""
};
println!(" {} {:<10} {}", icon, link_type, count);
for (i, (link_type, count)) in link_types.iter().enumerate() {
let t = i as f32 / link_types.len().max(1) as f32;
println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t));
}
println!();
println!(" 🔗 Links by Fact Type");
println!("{}", ui::gradient_text("─── Links by Fact Type ───"));
let mut fact_type_links: Vec<_> = stats.links_by_fact_type.iter().collect();
fact_type_links.sort_by_key(|(k, _)| *k);
for (fact_type, count) in fact_type_links {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {:<10} {}", icon, fact_type, count);
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
let t = i as f32 / fact_type_links.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
}
println!();
if !stats.links_breakdown.is_empty() {
println!(" 📈 Detailed Link Breakdown");
println!("{}", ui::gradient_text("─── Detailed Link Breakdown ───"));
let mut fact_types: Vec<_> = stats.links_breakdown.iter().collect();
fact_types.sort_by_key(|(k, _)| *k);
for (fact_type, link_types) in fact_types {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {}", icon, fact_type);
println!(" {}", fact_type);
let mut sorted_links: Vec<_> = link_types.iter().collect();
sorted_links.sort_by_key(|(k, _)| *k);
for (link_type, count) in sorted_links {
println!(" - {:<10} {}", link_type, count);
println!(" {:<10} {}", ui::dim(link_type), count);
}
}
println!();
}
if stats.pending_operations > 0 || stats.failed_operations > 0 {
println!(" ⚙️ Operations");
println!("{}", ui::gradient_text("─── Operations ───"));
if stats.pending_operations > 0 {
println!(" ⏳ Pending: {}", stats.pending_operations);
println!(" {} {}", ui::dim("pending:"), stats.pending_operations);
}
if stats.failed_operations > 0 {
println!(" ❌ Failed: {}", stats.failed_operations);
println!(" {} {}", ui::dim("failed:"), stats.failed_operations);
}
}
} else {
@@ -177,8 +154,8 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool,
let response = client.update_agent_name(bank_id, name, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -216,8 +193,8 @@ pub fn update_background(
let response = client.add_background(bank_id, content, !no_update_disposition, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -244,3 +221,57 @@ pub fn update_background(
Err(e) => Err(e)
}
}
pub fn delete(
client: &ApiClient,
bank_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete bank '{}' and ALL its data? This cannot be undone.",
bank_id
);
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting bank..."))
} else {
None
};
let response = client.delete_bank(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
if result.success {
ui::print_success(&format!("Bank '{}' deleted successfully", bank_id));
if let Some(count) = result.deleted_count {
println!(" Items deleted: {}", count);
}
} else {
ui::print_error("Failed to delete bank");
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
+7 -7
View File
@@ -20,14 +20,14 @@ pub fn list(
let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(docs_response) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Documents for agent '{}' (total: {})", agent_id, docs_response.total));
ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total));
for doc in &docs_response.items {
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown");
@@ -65,8 +65,8 @@ pub fn get(
let response = client.get_document(agent_id, document_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -102,8 +102,8 @@ pub fn delete(
let response = client.delete_document(agent_id, document_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+6 -17
View File
@@ -18,8 +18,8 @@ pub fn list(
let response = client.list_entities(bank_id, Some(limit), verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
@@ -66,8 +66,8 @@ pub fn get(
let response = client.get_entity(bank_id, entity_id, verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
@@ -84,17 +84,6 @@ pub fn get(
println!("Last seen: {}", last_seen);
}
// Show observations (always included)
if !response.observations.is_empty() {
println!("\nObservations ({}):", response.observations.len());
for obs in &response.observations {
println!(" - {}", obs.text);
if let Some(mentioned_at) = &obs.mentioned_at {
println!(" Mentioned at: {}", mentioned_at);
}
}
}
println!();
} else {
output::print_output(&response, output_format)?;
@@ -118,8 +107,8 @@ pub fn regenerate(
let response = client.regenerate_entity(bank_id, entity_id, verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
+185 -84
View File
@@ -16,8 +16,15 @@ use ratatui::{
Frame, Terminal,
};
use std::io;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
// Brand gradient colors: #0074d9 -> #009296
const BRAND_START: Color = Color::Rgb(0, 116, 217); // #0074d9
const BRAND_END: Color = Color::Rgb(0, 146, 150); // #009296
const BRAND_MID: Color = Color::Rgb(0, 131, 183); // Midpoint
/// Main view types (like k9s contexts)
#[derive(Debug, Clone, PartialEq)]
enum View {
@@ -61,6 +68,12 @@ enum InputMode {
Query,
}
/// Query result from background thread
enum QueryResult {
Recall(Result<Vec<RecallResult>, String>),
Reflect(Result<String, String>),
}
/// Application state
struct App {
client: ApiClient,
@@ -114,6 +127,9 @@ struct App {
auto_refresh_enabled: bool,
last_refresh: Instant,
refresh_interval: Duration,
// Background query receiver
query_receiver: Option<Receiver<QueryResult>>,
}
impl App {
@@ -160,6 +176,8 @@ impl App {
auto_refresh_enabled: true,
last_refresh: Instant::now(),
refresh_interval: Duration::from_secs(5),
query_receiver: None,
};
// Select first item by default
@@ -288,58 +306,106 @@ impl App {
Ok(())
}
fn execute_query(&mut self) -> Result<()> {
fn execute_query(&mut self) {
if let View::Query(bank_id) = &self.view {
if self.query_text.is_empty() {
self.error_message = "Query cannot be empty".to_string();
return Ok(());
return;
}
self.loading = true;
self.error_message.clear();
self.input_mode = InputMode::Normal;
match self.query_mode {
QueryMode::Recall => {
let request = RecallRequest {
query: self.query_text.clone(),
types: None,
budget: Some(self.query_budget.clone()),
max_tokens: self.query_max_tokens,
trace: false,
query_timestamp: None,
include: None,
};
// Create channel for receiving results
let (tx, rx) = mpsc::channel();
self.query_receiver = Some(rx);
let response = self.client.recall(bank_id, &request, false)?;
self.query_results = response.results;
// Clone data for the thread
let client = self.client.clone();
let bank_id = bank_id.clone();
let query_mode = self.query_mode.clone();
let query_text = self.query_text.clone();
let query_budget = self.query_budget.clone();
let query_max_tokens = self.query_max_tokens;
// Spawn background thread
thread::spawn(move || {
match query_mode {
QueryMode::Recall => {
let request = RecallRequest {
query: query_text,
types: None,
budget: Some(query_budget),
max_tokens: query_max_tokens,
trace: false,
query_timestamp: None,
include: None,
};
let result = client.recall(&bank_id, &request, false)
.map(|r| r.results)
.map_err(|e| e.to_string());
let _ = tx.send(QueryResult::Recall(result));
}
QueryMode::Reflect => {
let request = ReflectRequest {
query: query_text,
budget: Some(query_budget),
context: None,
include: None,
};
let result = client.reflect(&bank_id, &request, false)
.map(|r| r.text)
.map_err(|e| e.to_string());
let _ = tx.send(QueryResult::Reflect(result));
}
}
});
}
}
fn check_query_result(&mut self) {
if let Some(receiver) = &self.query_receiver {
match receiver.try_recv() {
Ok(QueryResult::Recall(Ok(results))) => {
self.query_results = results;
if !self.query_results.is_empty() {
self.query_results_state.select(Some(0));
}
self.loading = false;
self.status_message = format!("Found {} results", self.query_results.len());
self.query_receiver = None;
}
QueryMode::Reflect => {
let request = ReflectRequest {
query: self.query_text.clone(),
budget: Some(self.query_budget.clone()),
context: None,
include: None,
};
let response = self.client.reflect(bank_id, &request, false)?;
self.query_response = response.text;
Ok(QueryResult::Recall(Err(e))) => {
self.error_message = format!("Recall failed: {}", e);
self.loading = false;
self.query_receiver = None;
}
Ok(QueryResult::Reflect(Ok(text))) => {
self.query_response = text;
self.loading = false;
self.status_message = "Reflection complete".to_string();
self.query_receiver = None;
}
Ok(QueryResult::Reflect(Err(e))) => {
self.error_message = format!("Reflect failed: {}", e);
self.loading = false;
self.query_receiver = None;
}
Err(TryRecvError::Empty) => {
// Still waiting for result
}
Err(TryRecvError::Disconnected) => {
self.error_message = "Query thread disconnected".to_string();
self.loading = false;
self.query_receiver = None;
}
}
self.input_mode = InputMode::Normal;
}
Ok(())
}
fn toggle_query_mode(&mut self) {
@@ -700,64 +766,64 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
// Build contextual shortcuts based on view and input mode
let shortcuts = match (&app.view, &app.input_mode) {
(View::Banks, InputMode::Normal) => vec![
("Enter", "Select", Color::Cyan),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "Select", BRAND_START),
("R", "Refresh", BRAND_MID),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Memories(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("n", "Next", Color::Green),
("p", "Prev", Color::Green),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("n", "Next", BRAND_MID),
("p", "Prev", BRAND_MID),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Entities(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Documents(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("Del", "Delete", Color::Red),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Query(_), InputMode::Normal) => {
let mut shortcuts = vec![
("/", "Query", Color::Green),
("m", "Mode", Color::Cyan),
("/", "Query", BRAND_MID),
("m", "Mode", BRAND_START),
];
if app.query_mode == QueryMode::Recall {
shortcuts.push(("←→", "Scroll", Color::Cyan));
shortcuts.push(("←→", "Scroll", BRAND_START));
}
shortcuts.extend_from_slice(&[
("b", "Budget", Color::Yellow),
("+/-", "Tokens", Color::Yellow),
("Esc", "Back", Color::Yellow),
("?", "Help", Color::Magenta),
("b", "Budget", BRAND_END),
("+/-", "Tokens", BRAND_END),
("Esc", "Back", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
]);
shortcuts
},
(View::Query(_), InputMode::Query) => vec![
("Enter", "Execute", Color::Green),
("Enter", "Execute", BRAND_MID),
("Esc", "Cancel", Color::Red),
],
_ => vec![
("?", "Help", Color::Magenta),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
};
@@ -789,9 +855,9 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
let context_widget = Paragraph::new(context_info)
.block(Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(BRAND_START))
.title(" Context "))
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD))
.alignment(Alignment::Left);
f.render_widget(context_widget, columns[0]);
@@ -827,7 +893,7 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
let shortcuts_widget = Paragraph::new(shortcut_lines)
.block(Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(BRAND_START))
.title(" Shortcuts "))
.alignment(Alignment::Left);
@@ -844,7 +910,7 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) {
let title = format!("Hindsight Explorer - {}{}", app.view.title(), bank_info);
let header = Paragraph::new(title)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
@@ -859,11 +925,11 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
Span::raw(&app.error_message),
])
} else if app.loading {
Line::from(Span::styled(" Loading...", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))
Line::from(Span::styled(" Loading...", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)))
} else if !app.status_message.is_empty() {
Line::from(vec![
Span::raw(" "),
Span::styled(&app.status_message, Style::default().fg(Color::Green)),
Span::styled(&app.status_message, Style::default().fg(BRAND_MID)),
])
} else {
Line::from("")
@@ -928,7 +994,7 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Memory Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, chunks[0]);
@@ -946,7 +1012,7 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "MENTIONED AT", "OCCURRED AT", "TEXT"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1002,7 +1068,7 @@ fn render_entities(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Entity Details (Esc to close)"))
.style(Style::default().fg(Color::Cyan))
.style(Style::default().fg(BRAND_START))
.wrap(Wrap { trim: false });
f.render_widget(metadata, area);
@@ -1011,7 +1077,7 @@ fn render_entities(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<40} {:<15} {:<10}", "NAME", "TYPE", "MENTIONS"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1071,7 +1137,7 @@ fn render_documents(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Document Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, chunks[0]);
@@ -1091,7 +1157,7 @@ fn render_documents(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<40} {:<20} {}", "ID", "TYPE", "CREATED"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1137,7 +1203,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
// Query input
let query_style = if app.input_mode == InputMode::Query {
Style::default().fg(Color::Yellow)
Style::default().fg(BRAND_END)
} else {
Style::default()
};
@@ -1154,6 +1220,38 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
f.render_widget(query, chunks[0]);
// Show loading indicator if loading
if app.loading {
let loading_text = match app.query_mode {
QueryMode::Recall => "Searching memories...",
QueryMode::Reflect => "Reflecting on memories...",
};
// Create animated dots based on time
let dots = ".".repeat(((std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() / 500) % 4) as usize);
let loading_lines = vec![
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{}{}", loading_text, dots), Style::default().fg(BRAND_MID).add_modifier(Modifier::BOLD)),
]),
Line::from(""),
Line::from(Span::styled(" Please wait while we process your query...", Style::default().fg(Color::DarkGray))),
];
let loading_widget = Paragraph::new(loading_lines)
.block(Block::default().borders(Borders::ALL).title(format!("{} in progress", mode_label)))
.alignment(Alignment::Left);
f.render_widget(loading_widget, chunks[1]);
return;
}
// Results or Response based on mode
match app.query_mode {
QueryMode::Recall => {
@@ -1180,7 +1278,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Recall Result Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, recall_chunks[0]);
@@ -1196,7 +1294,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "OCCURRED START", "OCCURRED END", "TEXT"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1248,17 +1346,17 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
fn render_help(f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(vec![
Span::styled("Navigation Flow", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Navigation Flow", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" 1. Start by selecting a bank (Enter)"),
Line::from(" 2. View memories, entities, or documents for that bank"),
Line::from(" 3. Press / from any view to query (recall/reflect)"),
Line::from(""),
Line::from(vec![
Span::styled("Basic Navigation", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Basic Navigation", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" ↑/↓, j/k - Navigate up/down in lists"),
Line::from(" ←/→, h/l - Scroll text left/right in tables"),
@@ -1266,7 +1364,7 @@ fn render_help(f: &mut Frame, area: Rect) {
Line::from(" Esc - Go back / close detail view"),
Line::from(""),
Line::from(vec![
Span::styled("Query View", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Query View", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" / - Start or edit query (from any non-bank view)"),
Line::from(" m - Toggle mode (Recall ↔ Reflect)"),
@@ -1275,7 +1373,7 @@ fn render_help(f: &mut Frame, area: Rect) {
Line::from(" Enter - Execute query"),
Line::from(""),
Line::from(vec![
Span::styled("General", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("General", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" R - Refresh current view"),
Line::from(" ? - Toggle this help screen"),
@@ -1399,7 +1497,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
match key.code {
KeyCode::Enter => {
if matches!(app.view, View::Query(_)) {
app.execute_query()?;
app.execute_query();
}
}
KeyCode::Esc => {
@@ -1422,6 +1520,9 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
}
}
// Check for query results from background thread
app.check_query_result();
// Auto-refresh check
app.do_auto_refresh()?;
}
+14 -14
View File
@@ -63,8 +63,8 @@ pub fn recall(
let response = client.recall(agent_id, &request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -104,8 +104,8 @@ pub fn reflect(
let response = client.reflect(agent_id, &request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -154,8 +154,8 @@ pub fn retain(
let response = client.retain(agent_id, &request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -274,8 +274,8 @@ pub fn retain_files(
let response = client.retain(agent_id, &request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -312,8 +312,8 @@ pub fn delete(
let response = client.delete_memory(agent_id, unit_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -345,12 +345,12 @@ pub fn clear(
if !yes && output_format == OutputFormat::Pretty {
let message = if let Some(ft) = &fact_type {
format!(
"Are you sure you want to clear all '{}' memories for agent '{}'? This cannot be undone.",
"Are you sure you want to clear all '{}' memories for bank '{}'? This cannot be undone.",
ft, agent_id
)
} else {
format!(
"Are you sure you want to clear ALL memories for agent '{}'? This cannot be undone.",
"Are you sure you want to clear ALL memories for bank '{}'? This cannot be undone.",
agent_id
)
};
@@ -377,8 +377,8 @@ pub fn clear(
let response = client.clear_memories(agent_id, fact_type.as_deref(), verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+4 -4
View File
@@ -17,8 +17,8 @@ pub fn list(
let response = client.list_operations(agent_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -62,8 +62,8 @@ pub fn cancel(
let response = client.cancel_operation(agent_id, operation_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+5
View File
@@ -0,0 +1,5 @@
▄▄ ▄▄
▄ ▀▄ ▄▄▄ ▄▀ ▄
▀▀▄▄▄▄▀▀▀▄▄▄▄▀▀
▄▄▄ ▄ ▄▄▄
▄▀ ▀▀▄▄▄▀ ▀▄
+21 -3
View File
@@ -34,6 +34,7 @@ impl From<Format> for OutputFormat {
#[command(name = "hindsight")]
#[command(about = "Hindsight CLI - Semantic memory system", long_about = None)]
#[command(version)]
#[command(before_help = get_before_help())]
#[command(after_help = get_after_help())]
struct Cli {
/// Output format (pretty, json, yaml)
@@ -60,6 +61,10 @@ fn get_after_help() -> String {
)
}
fn get_before_help() -> &'static str {
ui::get_logo()
}
#[derive(Subcommand)]
enum Commands {
/// Manage banks (list, profile, stats)
@@ -100,8 +105,8 @@ enum BankCommands {
/// List all banks
List,
/// Get bank profile (disposition + background)
Profile {
/// Get bank disposition and background
Disposition {
/// Bank ID
bank_id: String,
},
@@ -133,6 +138,16 @@ enum BankCommands {
#[arg(long)]
no_update_disposition: bool,
},
/// Delete a bank and all its data
Delete {
/// Bank ID
bank_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
@@ -378,12 +393,15 @@ fn run() -> Result<()> {
Commands::Explore => commands::explore::run(&client),
Commands::Bank(bank_cmd) => match bank_cmd {
BankCommands::List => commands::bank::list(&client, verbose, output_format),
BankCommands::Profile { bank_id } => commands::bank::profile(&client, &bank_id, verbose, output_format),
BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format),
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
BankCommands::Background { bank_id, content, no_update_disposition } => {
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
}
BankCommands::Delete { bank_id, yes } => {
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
}
},
Commands::Memory(memory_cmd) => match memory_cmd {
+189 -81
View File
@@ -4,80 +4,132 @@ use hindsight_client::types::ChunkData;
use indicatif::{ProgressBar, ProgressStyle};
use std::io::{self, Write};
/// The logo as ANSI-colored text, generated by test-logo.py
const LOGO: &str = include_str!("logo.ansi");
// Gradient colors: #0074d9 -> #009296
const GRADIENT_START: (u8, u8, u8) = (0, 116, 217); // #0074d9
const GRADIENT_END: (u8, u8, u8) = (0, 146, 150); // #009296
/// Interpolate between two RGB colors
fn interpolate_color(start: (u8, u8, u8), end: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
(
(start.0 as f32 + (end.0 as f32 - start.0 as f32) * t) as u8,
(start.1 as f32 + (end.1 as f32 - start.1 as f32) * t) as u8,
(start.2 as f32 + (end.2 as f32 - start.2 as f32) * t) as u8,
)
}
/// Color text using gradient position (0.0 = start, 1.0 = end)
pub fn gradient(text: &str, t: f32) -> String {
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text)
}
/// Color text with gradient start color (#0074d9)
pub fn gradient_start(text: &str) -> String {
gradient(text, 0.0)
}
/// Color text with gradient end color (#009296)
pub fn gradient_end(text: &str) -> String {
gradient(text, 1.0)
}
/// Color text with gradient middle color
pub fn gradient_mid(text: &str) -> String {
gradient(text, 0.5)
}
/// Apply gradient across entire text string
pub fn gradient_text(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return String::new();
}
let mut result = String::new();
for (i, ch) in chars.iter().enumerate() {
if *ch == ' ' {
result.push(' ');
} else {
let t = i as f32 / (len - 1).max(1) as f32;
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch));
}
}
result.push_str("\x1b[0m");
result
}
/// Dim/gray text
pub fn dim(text: &str) -> String {
format!("\x1b[38;2;128;128;128m{}\x1b[0m", text)
}
pub fn get_logo() -> &'static str {
LOGO
}
pub fn print_section_header(title: &str) {
println!();
println!("{}", format!("━━━ {} ━━━", title).bright_yellow().bold());
println!("{}", gradient_text(&format!("━━━ {} ━━━", title)));
println!();
}
pub fn print_fact(fact: &RecallResult, show_activation: bool) {
pub fn print_fact(fact: &RecallResult, _show_activation: bool) {
let fact_type = fact.type_.as_deref().unwrap_or("unknown");
let type_color = match fact_type {
"world" => "cyan",
"agent" => "magenta",
"opinion" => "yellow",
_ => "white",
// Use gradient positions for different fact types
let type_t = match fact_type {
"world" => 0.0,
"agent" => 0.5,
"opinion" => 1.0,
_ => 0.5,
};
let prefix = match fact_type {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => "📝",
};
print!("{} ", prefix);
print!("{}", format!("[{}]", fact_type.to_uppercase()).color(type_color).bold());
// Note: activation field not available in generated SearchResult
// The API doesn't return it in the current schema
if show_activation {
// Placeholder for when activation is added to the API schema
}
println!();
println!("{}", gradient(&format!("[{}]", fact_type.to_uppercase()), type_t));
println!(" {}", fact.text);
// Show context if available
if let Some(context) = &fact.context {
println!(" {}: {}", "Context".bright_black(), context.bright_black());
println!(" {} {}", dim("context:"), dim(context));
}
// Show temporal information
if let Some(occurred_start) = &fact.occurred_start {
if let Some(occurred_end) = &fact.occurred_end {
println!(" {}: {} - {}", "Date".bright_black(), occurred_start.bright_black(), occurred_end.bright_black());
println!(" {} {} - {}", dim("date:"), dim(occurred_start), dim(occurred_end));
} else {
println!(" {}: {}", "Date".bright_black(), occurred_start.bright_black());
println!(" {} {}", dim("date:"), dim(occurred_start));
}
}
// Show document ID if available
if let Some(document_id) = &fact.document_id {
println!(" {}: {}", "Document".bright_black(), document_id.bright_black());
println!(" {} {}", dim("document:"), dim(document_id));
}
println!();
}
pub fn print_chunk(chunk: &ChunkData) {
println!(" {}", "─── Source Chunk ───".bright_blue());
println!(" {}", gradient_mid("─── Source Chunk ───"));
// Split text into lines and indent each line
for line in chunk.text.lines() {
println!(" {}", line.bright_white());
println!(" {}", line);
}
if chunk.truncated {
println!(" {}", "[Truncated due to token limit]".bright_yellow());
println!(" {}", gradient_end("[Truncated due to token limit]"));
}
println!(" {}: {} | {}: {}",
"Chunk ID".bright_black(),
chunk.id.bright_black(),
"Index".bright_black(),
chunk.chunk_index.to_string().bright_black()
println!(" {} {} | {} {}",
dim("Chunk ID:"),
dim(&chunk.id),
dim("Index:"),
dim(&chunk.chunk_index.to_string())
);
println!();
@@ -88,10 +140,10 @@ pub fn print_search_results(response: &RecallResponse, show_trace: bool, show_ch
print_section_header(&format!("Search Results ({})", results.len()));
if results.is_empty() {
println!("{}", " No results found.".bright_black());
println!(" {}", dim("No results found."));
} else {
for (i, fact) in results.iter().enumerate() {
println!("{}", format!(" Result #{}", i + 1).bright_black());
println!(" {}", dim(&format!("Result #{}", i + 1)));
print_fact(fact, true);
// Show chunk if available and requested
@@ -115,56 +167,120 @@ pub fn print_search_results(response: &RecallResponse, show_trace: bool, show_ch
}
pub fn print_think_response(response: &ReflectResponse) {
println!();
println!("{}", response.text.bright_white());
print_section_header("Reflection");
println!("{}", response.text);
println!();
if !response.based_on.is_empty() {
println!("{}", format!("Based on {} memory units", response.based_on.len()).bright_black());
println!("{}", dim(&format!("Based on {} memory units", response.based_on.len())));
}
}
pub fn print_trace_info(trace: &serde_json::Map<String, serde_json::Value>) {
print_section_header("Trace Information");
print_section_header("Trace");
if let Some(time) = trace.get("total_time").and_then(|v| v.as_f64()) {
println!(" ⏱️ Total time: {}", format!("{:.2}ms", time).bright_green());
println!(" {} {}", dim("total time:"), gradient_start(&format!("{:.2}ms", time)));
}
if let Some(count) = trace.get("activation_count").and_then(|v| v.as_i64()) {
println!(" 📊 Activation count: {}", count.to_string().bright_green());
println!(" {} {}", dim("activation count:"), gradient_end(&count.to_string()));
}
println!();
}
pub fn print_success(message: &str) {
println!("{} {}", "".bright_green().bold(), message.bright_white());
println!("{}", gradient_start(message));
}
pub fn print_error(message: &str) {
eprintln!("{} {}", "".bright_red().bold(), message.bright_red());
eprintln!("{} {}", "error:".bright_red().bold(), message.bright_red());
}
pub fn print_warning(message: &str) {
println!("{} {}", "".bright_yellow().bold(), message.bright_yellow());
println!("{} {}", gradient_end("warning:"), message);
}
pub fn print_info(message: &str) {
println!("{} {}", "".bright_blue().bold(), message.bright_white());
println!("{}", gradient_start(message));
}
pub fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
/// Animated gradient spinner that shows text with moving gradient colors
pub struct GradientSpinner {
message: String,
running: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl GradientSpinner {
pub fn new(message: &str) -> Self {
let message = message.to_string();
let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let msg_clone = message.clone();
let running_clone = running.clone();
let handle = std::thread::spawn(move || {
let chars: Vec<char> = msg_clone.chars().collect();
let len = chars.len();
let num_frames = 30;
let mut current_frame = 0usize;
while running_clone.load(std::sync::atomic::Ordering::Relaxed) {
current_frame = (current_frame + 1) % num_frames;
let offset = current_frame as f32 / num_frames as f32;
// Build the gradient string
let mut result = String::from("\r");
for (i, ch) in chars.iter().enumerate() {
if *ch == ' ' {
result.push(' ');
} else {
let base_t = if len > 1 { i as f32 / (len - 1) as f32 } else { 0.0 };
let t = (base_t + offset) % 1.0;
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch));
}
}
result.push_str("\x1b[0m");
print!("{}", result);
let _ = io::stdout().flush();
std::thread::sleep(std::time::Duration::from_millis(80));
}
});
Self {
message,
running,
handle: Some(handle),
}
}
pub fn finish(&mut self) {
self.running.store(false, std::sync::atomic::Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
// Clear the line
print!("\r{}\r", " ".repeat(self.message.len() + 10));
let _ = io::stdout().flush();
}
}
impl Drop for GradientSpinner {
fn drop(&mut self) {
if self.running.load(std::sync::atomic::Ordering::Relaxed) {
self.finish();
}
}
}
pub fn create_spinner(message: &str) -> GradientSpinner {
GradientSpinner::new(message)
}
pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
@@ -180,7 +296,7 @@ pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
}
pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
print!("{} {} [y/N]: ", "?".bright_blue().bold(), message);
print!("{} [y/N]: ", gradient_start(message));
io::stdout().flush()?;
let mut input = String::new();
@@ -189,16 +305,16 @@ pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes"))
}
pub fn print_profile(profile: &BankProfileResponse) {
print_section_header(&format!("Bank Profile: {}", profile.bank_id));
pub fn print_disposition(profile: &BankProfileResponse) {
print_section_header(&format!("Disposition: {}", profile.bank_id));
// Print name
println!("{} {}", "Name:".bright_cyan().bold(), profile.name.bright_white());
println!("{} {}", dim("Name:"), gradient_start(&profile.name));
println!();
// Print background if available
if !profile.background.is_empty() {
println!("{}", "Background:".bright_yellow());
println!("{}", gradient_mid("Background:"));
for line in profile.background.lines() {
println!("{}", line);
}
@@ -206,38 +322,30 @@ pub fn print_profile(profile: &BankProfileResponse) {
}
// Print disposition traits
println!("{}", "─── Disposition Traits ───".bright_yellow());
println!("{}", gradient_text("─── Disposition Traits ───"));
println!();
// New 3-trait disposition system (values 1-5)
let traits: [(_, i64, _, _, _); 3] = [
("Skepticism", profile.disposition.skepticism.get() as i64, "🔍", "cyan", "1=trusting, 5=skeptical"),
("Literalism", profile.disposition.literalism.get() as i64, "📋", "yellow", "1=flexible, 5=literal"),
("Empathy", profile.disposition.empathy.get() as i64, "💚", "green", "1=detached, 5=empathetic"),
let traits: [(_, i64, f32, _); 3] = [
("Skepticism", profile.disposition.skepticism.get() as i64, 0.0, "1=trusting, 5=skeptical"),
("Literalism", profile.disposition.literalism.get() as i64, 0.5, "1=flexible, 5=literal"),
("Empathy", profile.disposition.empathy.get() as i64, 1.0, "1=detached, 5=empathetic"),
];
for (name, value, emoji, color, desc) in &traits {
for (name, value, t, desc) in &traits {
// Scale 1-5 to bar visualization (each point = 8 chars, total 40)
let bar_length = 40;
let filled = ((*value - 1) * 10) as usize; // 1->0, 2->10, 3->20, 4->30, 5->40
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let colored_bar = match *color {
"green" => bar.bright_green(),
"yellow" => bar.bright_yellow(),
"cyan" => bar.bright_cyan(),
"magenta" => bar.bright_magenta(),
_ => bar.bright_white(),
};
println!(" {} {:<12} [{}] {}/5",
emoji,
println!(" {:<12} [{}] {}/5",
name,
colored_bar,
gradient(&bar, *t),
value
);
println!(" {}", desc.bright_black());
println!(" {}", dim(desc));
}
println!();
+83 -107
View File
@@ -1,12 +1,12 @@
{
"name": "hindsight-control-plane",
"version": "0.0.21",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hindsight-control-plane",
"version": "0.0.21",
"version": "0.1.2",
"license": "ISC",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
@@ -15,8 +15,11 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -33,19 +36,19 @@
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-cytoscape": "^1.0.6",
"react-dom": "^19.2.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"typescript": "^5.9.3"
}
},
"../hindsight-clients/typescript": {
"name": "@vectorize-io/hindsight-client",
"version": "0.0.21",
"version": "0.1.2",
"license": "MIT",
"devDependencies": {
"@hey-api/openapi-ts": "^0.88.0",
@@ -5045,6 +5048,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"license": "MIT",
@@ -5061,6 +5097,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"license": "MIT",
@@ -5324,6 +5389,12 @@
"tailwindcss": "4.1.17"
}
},
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -6219,31 +6290,13 @@
},
"node_modules/cytoscape": {
"version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-cola": {
"version": "2.5.1",
"license": "MIT",
"dependencies": {
"webcola": "^3.4.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-dagre": {
"version": "2.5.0",
"license": "MIT",
"dependencies": {
"dagre": "^0.8.5"
},
"peerDependencies": {
"cytoscape": "^3.2.22"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -6265,18 +6318,6 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "1.0.6",
"license": "BSD-3-Clause"
},
"node_modules/d3-drag": {
"version": "1.2.5",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1",
"d3-selection": "1"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -6307,10 +6348,6 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "1.0.9",
"license": "BSD-3-Clause"
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -6327,17 +6364,6 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "1.4.2",
"license": "BSD-3-Clause"
},
"node_modules/d3-shape": {
"version": "1.3.7",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -6362,18 +6388,6 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "1.0.10",
"license": "BSD-3-Clause"
},
"node_modules/dagre": {
"version": "0.8.5",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"license": "BSD-2-Clause"
@@ -7215,17 +7229,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -7389,13 +7392,6 @@
"version": "1.4.0",
"license": "MIT"
},
"node_modules/graphlib": {
"version": "2.1.8",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"license": "MIT",
@@ -8044,10 +8040,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"license": "MIT"
@@ -8550,18 +8542,6 @@
"styled-components": "^6.0.0"
}
},
"node_modules/react-cytoscape": {
"version": "1.0.6",
"license": "MIT",
"dependencies": {
"cytoscape": "^3.2.5",
"cytoscape-cola": "^2.0.0",
"cytoscape-dagre": "^2.1.0"
},
"optionalDependencies": {
"fsevents": "*"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"license": "MIT",
@@ -9315,6 +9295,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -9713,16 +9699,6 @@
"node": ">=12"
}
},
"node_modules/webcola": {
"version": "3.4.0",
"license": "MIT",
"dependencies": {
"d3-dispatch": "^1.0.3",
"d3-drag": "^1.0.4",
"d3-shape": "^1.3.5",
"d3-timer": "^1.0.5"
}
},
"node_modules/which": {
"version": "2.0.2",
"license": "ISC",
+4 -1
View File
@@ -19,8 +19,11 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -37,13 +40,13 @@
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-cytoscape": "^1.0.6",
"react-dom": "^19.2.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"typescript": "^5.9.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+46 -36
View File
@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap');
@import "tailwindcss";
:root {
--background: oklch(0.9911 0 0);
@@ -6,8 +7,9 @@
--card-foreground: oklch(0.2046 0 0);
--popover: oklch(0.9911 0 0);
--popover-foreground: oklch(0.4386 0 0);
--primary: oklch(0.8348 0.1302 160.9080);
--primary-foreground: oklch(0.2626 0.0147 166.4589);
--primary: oklch(0.55 0.19 250);
--primary-foreground: oklch(0.98 0.01 250);
--primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--secondary: oklch(0.9940 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9461 0 0);
@@ -18,23 +20,23 @@
--destructive-foreground: oklch(0.9934 0.0032 17.2118);
--border: oklch(0.9037 0 0);
--input: oklch(0.9731 0 0);
--ring: oklch(0.8348 0.1302 160.9080);
--chart-1: oklch(0.8348 0.1302 160.9080);
--ring: oklch(0.55 0.19 250);
--chart-1: oklch(0.55 0.19 250);
--chart-2: oklch(0.6231 0.1880 259.8145);
--chart-3: oklch(0.6056 0.2189 292.7172);
--chart-4: oklch(0.7686 0.1647 70.0804);
--chart-5: oklch(0.6959 0.1491 162.4796);
--sidebar: oklch(0.9911 0 0);
--sidebar-foreground: oklch(0.5452 0 0);
--sidebar-primary: oklch(0.8348 0.1302 160.9080);
--sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589);
--sidebar-primary: oklch(0.55 0.19 250);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.9461 0 0);
--sidebar-accent-foreground: oklch(0.2435 0 0);
--sidebar-border: oklch(0.9037 0 0);
--sidebar-ring: oklch(0.8348 0.1302 160.9080);
--font-sans: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--sidebar-ring: oklch(0.55 0.19 250);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-heading: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
@@ -61,8 +63,9 @@
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2603 0 0);
--popover-foreground: oklch(0.7348 0 0);
--primary: oklch(0.4365 0.1044 156.7556);
--primary-foreground: oklch(0.9213 0.0135 167.1556);
--primary: oklch(0.60 0.17 250);
--primary-foreground: oklch(0.98 0.01 250);
--primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--secondary: oklch(0.2603 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2393 0 0);
@@ -73,38 +76,20 @@
--destructive-foreground: oklch(0.9368 0.0045 34.3092);
--border: oklch(0.2809 0 0);
--input: oklch(0.2603 0 0);
--ring: oklch(0.8003 0.1821 151.7110);
--chart-1: oklch(0.8003 0.1821 151.7110);
--ring: oklch(0.60 0.17 250);
--chart-1: oklch(0.60 0.17 250);
--chart-2: oklch(0.7137 0.1434 254.6240);
--chart-3: oklch(0.7090 0.1592 293.5412);
--chart-4: oklch(0.8369 0.1644 84.4286);
--chart-5: oklch(0.7845 0.1325 181.9120);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.6301 0 0);
--sidebar-primary: oklch(0.4365 0.1044 156.7556);
--sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556);
--sidebar-primary: oklch(0.60 0.17 250);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.3132 0 0);
--sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(0.2809 0 0);
--sidebar-ring: oklch(0.8003 0.1821 151.7110);
--font-sans: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.17;
--shadow-color: #000000;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--sidebar-ring: oklch(0.60 0.17 250);
}
@theme inline {
@@ -143,7 +128,7 @@
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-heading: var(--font-heading);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -170,4 +155,29 @@
body {
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
}
code, pre {
font-family: var(--font-mono);
}
/* Gradient utilities */
.bg-primary-gradient {
background: var(--primary-gradient);
}
.border-primary-gradient {
border-image: var(--primary-gradient) 1;
}
.text-primary-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
+10 -4
View File
@@ -1,10 +1,14 @@
import type { Metadata } from "next";
import "./globals.css";
import { BankProvider } from "@/lib/bank-context";
import { ThemeProvider } from "@/lib/theme-context";
export const metadata: Metadata = {
title: "Hindsight Control Plane",
description: "Control plane for the temporal semantic memory system",
icons: {
icon: "/favicon.png",
},
};
export default function RootLayout({
@@ -13,11 +17,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body>
<BankProvider>
{children}
</BankProvider>
<ThemeProvider>
<BankProvider>
{children}
</BankProvider>
</ThemeProvider>
</body>
</html>
);
@@ -27,7 +27,9 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Check, ChevronsUpDown, Plus, FileText } from 'lucide-react';
import { Check, ChevronsUpDown, Plus, FileText, Moon, Sun, Github } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import Image from 'next/image';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
@@ -36,6 +38,7 @@ function BankSelectorInner() {
const router = useRouter();
const searchParams = useSearchParams();
const { currentBank, setCurrentBank, banks, loadBanks } = useBank();
const { theme, toggleTheme } = useTheme();
const [open, setOpen] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const [newBankId, setNewBankId] = React.useState('');
@@ -119,26 +122,34 @@ function BankSelectorInner() {
};
return (
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary">
<div className="flex items-center gap-2.5 text-sm">
<span className="font-medium">Memory Bank:</span>
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
<div className="flex items-center gap-4 text-sm">
{/* Logo */}
<Image src="/logo.png" alt="Hindsight" width={40} height={40} className="h-10 w-auto" unoptimized />
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Memory Bank Selector */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[300px] justify-between font-bold border-2 border-primary hover:bg-accent"
className="w-[250px] justify-between font-bold border-2 border-primary hover:bg-accent"
>
{currentBank || "Select a memory bank..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<PopoverContent className="w-[250px] p-0">
<Command>
<CommandInput placeholder="Search memory banks..." />
{sortedBanks.length > 0 && (
<CommandInput placeholder="Search memory banks..." />
)}
<CommandList>
<CommandEmpty>No memory bank found.</CommandEmpty>
<CommandEmpty>No memory banks yet.</CommandEmpty>
<CommandGroup>
{sortedBanks.map((bank) => (
<CommandItem
@@ -165,34 +176,73 @@ function BankSelectorInner() {
))}
</CommandGroup>
</CommandList>
{/* Footer: Create new bank */}
<div className="border-t border-border p-1">
<button
className="w-full flex items-center gap-2 px-2 py-2 text-sm rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={() => {
setOpen(false);
setCreateDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
<span>Create new bank</span>
</button>
</div>
</Command>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-primary hover:bg-accent gap-1.5"
onClick={() => setCreateDialogOpen(true)}
title="Create new memory bank"
>
<Plus className="h-4 w-4" />
<span>New Bank</span>
</Button>
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Add Document Button */}
{currentBank && (
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-secondary hover:bg-secondary/20 gap-1.5"
className="h-9 gap-1.5"
onClick={() => setDocDialogOpen(true)}
title="Add document to current bank"
>
<FileText className="h-4 w-4" />
<span>New Document</span>
<Plus className="h-4 w-4" />
<span>Add Document</span>
</Button>
)}
{/* Spacer */}
<div className="flex-1" />
{/* GitHub Link */}
<a
href="https://github.com/vectorize-io/hindsight"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="View on GitHub"
>
<Github className="h-5 w-5" />
<span className="text-sm font-medium">GitHub</span>
</a>
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Dark Mode Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-9 w-9"
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
@@ -333,17 +383,32 @@ function BankSelectorInner() {
export function BankSelector() {
return (
<Suspense fallback={
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary">
<div className="flex items-center gap-2.5 text-sm">
<span className="font-medium">Memory Bank:</span>
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
<div className="flex items-center gap-4 text-sm">
<Image src="/logo.png" alt="Hindsight" width={40} height={40} className="h-10 w-auto" unoptimized />
<div className="h-8 w-px bg-border" />
<Button
variant="outline"
className="w-[300px] justify-between font-bold border-2 border-primary"
className="w-[250px] justify-between font-bold border-2 border-primary"
disabled
>
Loading...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<div className="flex-1" />
<a
href="https://github.com/vectorize-io/hindsight"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground"
>
<Github className="h-5 w-5" />
<span className="text-sm font-medium">GitHub</span>
</a>
<div className="h-8 w-px bg-border" />
<Button variant="ghost" size="icon" className="h-9 w-9" disabled>
<Moon className="h-5 w-5" />
</Button>
</div>
</div>
}>
@@ -1,15 +1,17 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { client } from '@/lib/api';
import { useBank } from '@/lib/bank-context';
import cytoscape from 'cytoscape';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Copy, Check, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Copy, Check, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, Eye, EyeOff } from 'lucide-react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { MemoryDetailPanel } from './memory-detail-panel';
import { Graph2D, convertHindsightGraphData, GraphNode } from './graph-2d';
type FactType = 'world' | 'experience' | 'opinion';
type ViewMode = 'graph' | 'table' | 'timeline';
@@ -23,16 +25,41 @@ export function DataView({ factType }: DataViewProps) {
const [viewMode, setViewMode] = useState<ViewMode>('graph');
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [nodeLimit, setNodeLimit] = useState(50);
const [layout, setLayout] = useState('circle');
const [searchQuery, setSearchQuery] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [selectedTableMemory, setSelectedTableMemory] = useState<any>(null);
const itemsPerPage = 100;
const cyRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(50);
const [showControlPanel, setShowControlPanel] = useState(true);
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(new Set(['semantic', 'temporal', 'entity', 'causal']));
const toggleLinkType = (type: string) => {
setVisibleLinkTypes(prev => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
// Esc key handler to deselect graph node
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedGraphNode) {
setSelectedGraphNode(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedGraphNode]);
const copyToClipboard = async (text: string) => {
try {
@@ -68,118 +95,98 @@ export function DataView({ factType }: DataViewProps) {
}
};
const renderGraph = () => {
if (!data || !containerRef.current || !data.nodes || !data.edges) return;
// Filter table rows based on search query (text only)
const filteredTableRows = useMemo(() => {
if (!data?.table_rows) return [];
if (!searchQuery) return data.table_rows;
if (cyRef.current) {
cyRef.current.destroy();
}
const limitedNodes = (data.nodes || []).slice(0, nodeLimit);
const nodeIds = new Set(limitedNodes.map((n: any) => n.data.id));
const limitedEdges = (data.edges || []).filter((e: any) =>
nodeIds.has(e.data.source) && nodeIds.has(e.data.target)
const query = searchQuery.toLowerCase();
return data.table_rows.filter((row: any) =>
row.text?.toLowerCase().includes(query)
);
}, [data, searchQuery]);
const layouts: any = {
circle: {
name: 'circle',
animate: false,
radius: 300,
spacingFactor: 1.5,
},
grid: {
name: 'grid',
animate: false,
rows: Math.ceil(Math.sqrt(limitedNodes.length)),
cols: Math.ceil(Math.sqrt(limitedNodes.length)),
spacingFactor: 2,
},
cose: {
name: 'cose',
animate: false,
nodeRepulsion: 15000,
idealEdgeLength: 150,
edgeElasticity: 100,
nestingFactor: 1.2,
gravity: 1,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0,
},
};
// Get filtered node IDs for graph filtering
const filteredNodeIds = useMemo(() => {
return new Set(filteredTableRows.map((row: any) => row.id));
}, [filteredTableRows]);
cyRef.current = cytoscape({
container: containerRef.current,
elements: [
...limitedNodes.map((n: any) => ({ data: n.data })),
...limitedEdges.map((e: any) => ({ data: e.data })),
],
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)' as any,
label: 'data(label)' as any,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'font-weight': 'bold',
'text-wrap': 'wrap',
'text-max-width': '100px',
width: 40,
height: 40,
'border-width': 2,
'border-color': '#333',
},
},
{
selector: 'edge',
style: {
width: 1,
'line-color': 'data(color)' as any,
'line-style': 'data(lineStyle)' as any,
'target-arrow-shape': 'triangle',
'target-arrow-color': 'data(color)' as any,
'curve-style': 'bezier',
opacity: 0.6,
},
},
{
selector: 'node:selected',
style: {
'border-width': 4,
'border-color': '#000',
},
},
] as any,
layout: layouts[layout] || layouts.circle,
});
// Add click handler for nodes
cyRef.current.on('tap', 'node', (evt: any) => {
const nodeId = evt.target.id();
// Find the corresponding table row data
const nodeData = data.table_rows?.find((row: any) => row.id === nodeId);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
});
// Click on background to deselect
cyRef.current.on('tap', (evt: any) => {
if (evt.target === cyRef.current) {
setSelectedGraphNode(null);
}
});
// Helper to get normalized link type
const getLinkTypeCategory = (type: string | undefined): string => {
if (!type) return 'semantic';
if (type === 'semantic' || type === 'temporal' || type === 'entity') return type;
if (['causes', 'caused_by', 'enables', 'prevents'].includes(type)) return 'causal';
return 'semantic';
};
useEffect(() => {
if (viewMode === 'graph' && data) {
renderGraph();
// Convert data for Graph2D with filtering
const graph2DData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
const fullData = convertHindsightGraphData(data);
let nodes = fullData.nodes;
let links = fullData.links;
// Filter nodes based on search query
if (searchQuery) {
const filteredNodes = fullData.nodes.filter(node => filteredNodeIds.has(node.id));
const filteredNodeIdSet = new Set(filteredNodes.map(n => n.id));
nodes = filteredNodes;
links = fullData.links.filter(link =>
filteredNodeIdSet.has(link.source) && filteredNodeIdSet.has(link.target)
);
}
}, [viewMode, data, nodeLimit, layout]);
// Filter links based on visible link types
links = links.filter(link => {
const category = getLinkTypeCategory(link.type);
return visibleLinkTypes.has(category);
});
return { nodes, links };
}, [data, searchQuery, filteredNodeIds, visibleLinkTypes]);
// Calculate link stats for display
const linkStats = useMemo(() => {
let semantic = 0, temporal = 0, entity = 0, causal = 0, total = 0;
const otherTypes: Record<string, number> = {};
graph2DData.links.forEach(l => {
total++;
const type = l.type || 'unknown';
if (type === 'semantic') semantic++;
else if (type === 'temporal') temporal++;
else if (type === 'entity') entity++;
else if (type === 'causes' || type === 'caused_by' || type === 'enables' || type === 'prevents') causal++;
else {
otherTypes[type] = (otherTypes[type] || 0) + 1;
}
});
console.log('Graph link stats:', { semantic, temporal, entity, causal, total });
if (Object.keys(otherTypes).length > 0) {
console.log('Other link types:', otherTypes);
}
return { semantic, temporal, entity, causal, total, otherTypes };
}, [graph2DData]);
// Handle node click in graph - show in panel
const handleGraphNodeClick = useCallback((node: GraphNode) => {
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
}, [data]);
// Memoized color functions to prevent graph re-initialization
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
const nodeColorFn = useCallback((node: GraphNode) => node.color || '#0074d9', []);
const linkColorFn = useCallback((link: any) => {
if (link.type === 'temporal') return '#009296'; // Brand teal
if (link.type === 'entity') return '#f59e0b'; // Amber
if (link.type === 'causes' || link.type === 'caused_by' || link.type === 'enables' || link.type === 'prevents') {
return '#8b5cf6'; // Purple for causal
}
return '#0074d9'; // Brand primary blue for semantic
}, []);
// Reset to first page when search query changes
useEffect(() => {
@@ -204,9 +211,20 @@ export function DataView({ factType }: DataViewProps) {
</div>
) : data ? (
<>
{/* Always visible filter */}
<div className="mb-4">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter memories by text..."
className="max-w-md"
/>
</div>
<div className="flex items-center justify-between mb-6">
<div className="text-sm text-muted-foreground">
{data.total_units} total memories
{searchQuery ? `${filteredTableRows.length} of ${data.total_units} memories` : `${data.total_units} total memories`}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
@@ -243,134 +261,214 @@ export function DataView({ factType }: DataViewProps) {
</div>
{viewMode === 'graph' && (
<div className="flex gap-4">
<div className={`relative transition-all ${selectedGraphNode ? 'w-2/3' : 'w-full'}`}>
<div className="p-4 bg-card border-b-2 border-primary flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Limit nodes:</label>
<Input
type="number"
value={nodeLimit}
onChange={(e) => setNodeLimit(parseInt(e.target.value))}
min="10"
max="1000"
step="10"
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Layout:</label>
<Select value={layout} onValueChange={setLayout}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="circle">Circle (fast)</SelectItem>
<SelectItem value="grid">Grid (fast)</SelectItem>
<SelectItem value="cose">Force-directed (slow)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground ml-auto">
Click on a node to view details
</div>
</div>
<div ref={containerRef} className="w-full h-[800px] bg-background" />
<div className="absolute top-20 left-5 bg-card p-4 border-2 border-primary rounded-lg shadow-lg max-w-[250px]">
<h3 className="font-bold mb-2 border-b-2 border-primary pb-1 text-card-foreground">Legend</h3>
<h4 className="font-bold mt-2 mb-1 text-sm text-card-foreground">Link Types:</h4>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-cyan-500 border-t border-dashed border-cyan-500" />
<span className="text-sm"><strong>Temporal</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-pink-500" />
<span className="text-sm"><strong>Semantic</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-yellow-500" />
<span className="text-sm"><strong>Entity</strong></span>
</div>
<h4 className="font-bold mt-2 mb-1 text-sm">Nodes:</h4>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-gray-300 border border-gray-500 rounded" />
<span className="text-sm">No entities</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-300 border border-gray-500 rounded" />
<span className="text-sm">1 entity</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-500 border border-gray-500 rounded" />
<span className="text-sm">2+ entities</span>
</div>
</div>
<div className="flex gap-0">
{/* Graph */}
<div className="flex-1 min-w-0">
<Graph2D
data={graph2DData}
height={700}
showLabels={showLabels}
onNodeClick={handleGraphNodeClick}
maxNodes={maxNodes}
nodeColorFn={nodeColorFn}
linkColorFn={linkColorFn}
/>
</div>
{/* Memory Detail Panel for Graph View - Fixed on Right */}
{selectedGraphNode && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
/>
{/* Right Toggle Button */}
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? 'Hide panel' : 'Show panel'}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
)}
</button>
{/* Right Panel - Legend/Controls OR Memory Details */}
<div className={`${showControlPanel ? 'w-80' : 'w-0'} transition-all duration-300 overflow-hidden flex-shrink-0`}>
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
{selectedGraphNode ? (
/* Memory Detail View */
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
/>
) : (
/* Legend & Controls View */
<div className="p-4 space-y-5">
{/* Legend & Stats */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Graph</h3>
<div className="space-y-2">
{/* Nodes */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#0074d9' }} />
<span className="text-foreground">Nodes</span>
</div>
<span className="font-mono text-foreground">
{Math.min(maxNodes ?? graph2DData.nodes.length, graph2DData.nodes.length)}/{graph2DData.nodes.length}
</span>
</div>
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">Links ({linkStats.total}) <span className="text-muted-foreground/60">· click to filter</span></div>
<button
onClick={() => toggleLinkType('semantic')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('semantic') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#0074d9]" />
<span className="text-foreground">Semantic</span>
</div>
<span className={`font-mono ${linkStats.semantic === 0 ? 'text-destructive' : 'text-foreground'}`}>
{linkStats.semantic}
</span>
</button>
<button
onClick={() => toggleLinkType('temporal')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('temporal') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#009296]" />
<span className="text-foreground">Temporal</span>
</div>
<span className={`font-mono ${linkStats.temporal === 0 ? 'text-destructive' : 'text-foreground'}`}>
{linkStats.temporal}
</span>
</button>
<button
onClick={() => toggleLinkType('entity')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('entity') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#f59e0b]" />
<span className="text-foreground">Entity</span>
</div>
<span className="font-mono text-foreground">{linkStats.entity}</span>
</button>
<button
onClick={() => toggleLinkType('causal')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('causal') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
<span className="text-foreground">Causal</span>
</div>
<span className={`font-mono ${linkStats.causal === 0 ? 'text-muted-foreground' : 'text-foreground'}`}>
{linkStats.causal}
</span>
</button>
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground capitalize ml-6">{type}</span>
<span className="font-mono text-muted-foreground">{count as number}</span>
</div>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Controls Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Display</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="show-labels" className="text-sm text-foreground">Show labels</Label>
<Switch
id="show-labels"
checked={showLabels}
onCheckedChange={setShowLabels}
/>
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Limits Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Performance</h3>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm text-foreground">Max nodes</Label>
<span className="text-xs text-muted-foreground">
{maxNodes ?? 'All'} / {graph2DData.nodes.length}
</span>
</div>
<Slider
value={[maxNodes ?? graph2DData.nodes.length]}
min={10}
max={Math.max(graph2DData.nodes.length, 10)}
step={10}
onValueChange={([v]) => setMaxNodes(v >= graph2DData.nodes.length ? undefined : v)}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground">
All links between visible nodes are shown.
</p>
</div>
</div>
<div className="border-t border-border" />
{/* Hint */}
<div className="text-xs text-muted-foreground/60 text-center pt-2">
Click a node to see details
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{viewMode === 'table' && (
<div>
<div className="w-full">
<div className="px-5 mb-4">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search memories (text, context, ID)..."
className="max-w-2xl"
/>
</div>
<div className="px-5 pb-5">
{data.table_rows && data.table_rows.length > 0 ? (
<div className="pb-4">
{filteredTableRows.length > 0 ? (
(() => {
const filteredRows = data.table_rows.filter((row: any) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query) ||
row.id?.toLowerCase().includes(query)
);
});
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const totalPages = Math.ceil(filteredTableRows.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, endIndex);
const paginatedRows = filteredTableRows.slice(startIndex, endIndex);
return (
<>
<div className="border rounded-lg overflow-hidden">
<Table>
<Table className="table-fixed">
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Text</TableHead>
<TableHead className="w-[150px]">Context</TableHead>
<TableHead className="w-[100px]">Occurred</TableHead>
<TableHead className="w-[100px]">Mentioned</TableHead>
<TableHead className="w-[60px]">Actions</TableHead>
<TableHead className="w-[45%]">Memory</TableHead>
<TableHead className="w-[20%]">Entities</TableHead>
<TableHead className="w-[15%]">Occurred</TableHead>
<TableHead className="w-[15%]">Mentioned</TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedRows.map((row: any, idx: number) => {
const occurredDisplay = row.occurred_start
? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: null;
const mentionedDisplay = row.mentioned_at
? new Date(row.mentioned_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
? new Date(row.mentioned_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: null;
return (
@@ -381,46 +479,40 @@ export function DataView({ factType }: DataViewProps) {
selectedTableMemory?.id === row.id ? 'bg-primary/10' : ''
}`}
>
<TableCell className="font-mono text-xs text-muted-foreground" title={row.id}>
{row.id?.substring(0, 8)}...
<TableCell className="py-2">
<div className="line-clamp-2 text-sm leading-snug">{row.text}</div>
{row.context && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{row.context}</div>
)}
</TableCell>
<TableCell>
<div className="line-clamp-2 text-sm">{row.text}</div>
{row.entities && (
<div className="flex gap-1 mt-1 flex-wrap">
{row.entities.split(', ').slice(0, 3).map((entity: string, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-secondary-foreground">
<TableCell className="py-2">
{row.entities ? (
<div className="flex gap-1 flex-wrap">
{row.entities.split(', ').slice(0, 2).map((entity: string, i: number) => (
<span
key={i}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium"
>
{entity}
</span>
))}
{row.entities.split(', ').length > 3 && (
{row.entities.split(', ').length > 2 && (
<span className="text-[10px] text-muted-foreground">
+{row.entities.split(', ').length - 3}
+{row.entities.split(', ').length - 2}
</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[150px]" title={row.context}>
{row.context || '-'}
<TableCell className="text-xs py-2">
{occurredDisplay || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-xs">
{occurredDisplay ? (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{occurredDisplay}
</span>
) : '-'}
<TableCell className="text-xs py-2">
{mentionedDisplay || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-xs">
{mentionedDisplay ? (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{mentionedDisplay}
</span>
) : '-'}
</TableCell>
<TableCell>
<TableCell className="py-2">
<Button
onClick={(e) => {
e.stopPropagation();
@@ -428,7 +520,7 @@ export function DataView({ factType }: DataViewProps) {
}}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
className="h-6 w-6 p-0"
title="Copy ID"
>
{copiedId === row.id ? (
@@ -447,9 +539,9 @@ export function DataView({ factType }: DataViewProps) {
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(endIndex, filteredRows.length)} of {filteredRows.length}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<div className="text-xs text-muted-foreground">
{startIndex + 1}-{Math.min(endIndex, filteredTableRows.length)} of {filteredTableRows.length}
</div>
<div className="flex items-center gap-1">
<Button
@@ -457,20 +549,20 @@ export function DataView({ factType }: DataViewProps) {
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronsLeft className="h-4 w-4" />
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-sm px-3">
<span className="text-xs px-2">
{currentPage} / {totalPages}
</span>
<Button
@@ -478,18 +570,18 @@ export function DataView({ factType }: DataViewProps) {
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronsRight className="h-4 w-4" />
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
</div>
@@ -499,7 +591,7 @@ export function DataView({ factType }: DataViewProps) {
})()
) : (
<div className="text-center py-12 text-muted-foreground">
{data.table_rows ? 'No memories match your search' : 'No memories found'}
{data.table_rows?.length > 0 ? 'No memories match your filter' : 'No memories found'}
</div>
)}
</div>
@@ -519,7 +611,7 @@ export function DataView({ factType }: DataViewProps) {
)}
{viewMode === 'timeline' && (
<TimelineView data={data} />
<TimelineView data={data} filteredRows={filteredTableRows} />
)}
</>
) : (
@@ -537,17 +629,17 @@ export function DataView({ factType }: DataViewProps) {
// Timeline View Component - Custom compact timeline with zoom and navigation
type Granularity = 'year' | 'month' | 'week' | 'day';
function TimelineView({ data }: { data: any }) {
function TimelineView({ data, filteredRows }: { data: any; filteredRows: any[] }) {
const [selectedItem, setSelectedItem] = useState<any>(null);
const [granularity, setGranularity] = useState<Granularity>('month');
const [currentIndex, setCurrentIndex] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null);
// Filter and sort items that have occurred_start dates
// Filter and sort items that have occurred_start dates (using filtered data)
const { sortedItems, itemsWithoutDates } = useMemo(() => {
if (!data?.table_rows) return { sortedItems: [], itemsWithoutDates: [] };
if (!filteredRows || filteredRows.length === 0) return { sortedItems: [], itemsWithoutDates: [] };
const withDates = data.table_rows
const withDates = filteredRows
.filter((row: any) => row.occurred_start)
.sort((a: any, b: any) => {
const dateA = new Date(a.occurred_start).getTime();
@@ -555,19 +647,10 @@ function TimelineView({ data }: { data: any }) {
return dateA - dateB;
});
const withoutDates = data.table_rows.filter((row: any) => !row.occurred_start);
// Debug logging
console.log('Timeline data:', {
total: data.table_rows.length,
withDates: withDates.length,
withoutDates: withoutDates.length,
sampleWithDate: withDates[0],
sampleWithoutDate: withoutDates[0]
});
const withoutDates = filteredRows.filter((row: any) => !row.occurred_start);
return { sortedItems: withDates, itemsWithoutDates: withoutDates };
}, [data]);
}, [filteredRows]);
// Group items by granularity
const timelineGroups = useMemo(() => {
@@ -697,9 +780,9 @@ function TimelineView({ data }: { data: any }) {
};
return (
<div className="flex gap-3 px-4">
<div className="px-4">
{/* Timeline */}
<div className={`transition-all ${selectedItem ? 'w-2/3' : 'w-full'}`}>
<div>
{/* Controls */}
<div className="flex items-center justify-between mb-3 gap-4">
<div className="text-xs text-muted-foreground">
@@ -853,7 +936,7 @@ function TimelineView({ data }: { data: any }) {
{item.entities && (
<div className="flex gap-1 mt-1 flex-wrap">
{item.entities.split(', ').slice(0, 3).map((entity: string, i: number) => (
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-secondary text-secondary-foreground">
<span key={i} className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{entity}
</span>
))}
@@ -0,0 +1,599 @@
'use client';
import { useRef, useEffect, useState, useMemo } from 'react';
import cytoscape, { Core, NodeSingular } from 'cytoscape';
// Hook to detect dark mode
function useIsDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDark = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDark();
// Watch for theme changes
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface GraphNode {
id: string;
label?: string;
color?: string;
size?: number;
group?: string;
metadata?: Record<string, any>;
}
export interface GraphLink {
source: string;
target: string;
color?: string;
width?: number;
type?: string;
entity?: string;
weight?: number;
metadata?: Record<string, any>;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface Graph2DProps {
data: GraphData;
height?: number;
showLabels?: boolean;
onNodeClick?: (node: GraphNode) => void;
onNodeHover?: (node: GraphNode | null) => void;
nodeColorFn?: (node: GraphNode) => string;
nodeSizeFn?: (node: GraphNode) => number;
linkColorFn?: (link: GraphLink) => string;
linkWidthFn?: (link: GraphLink) => number;
maxNodes?: number;
}
// ============================================================================
// Default Values
// ============================================================================
// Brand colors
const BRAND_PRIMARY = '#0074d9';
const BRAND_TEAL = '#009296';
const LINK_SEMANTIC = '#0074d9'; // Primary blue for semantic
const LINK_TEMPORAL = '#009296'; // Teal for temporal
const LINK_ENTITY = '#f59e0b'; // Amber for entity
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
const DEFAULT_NODE_SIZE = 20;
const DEFAULT_LINK_WIDTH = 1;
// ============================================================================
// Component
// ============================================================================
export function Graph2D({
data,
height = 600,
showLabels = true,
onNodeClick,
onNodeHover,
nodeColorFn,
nodeSizeFn,
linkColorFn,
linkWidthFn,
maxNodes,
}: Graph2DProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<Core | null>(null);
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isDarkMode = useIsDarkMode();
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
const onNodeClickRef = useRef(onNodeClick);
const onNodeHoverRef = useRef(onNodeHover);
const fullDataRef = useRef(data);
const nodeColorFnRef = useRef(nodeColorFn);
const linkColorFnRef = useRef(linkColorFn);
onNodeClickRef.current = onNodeClick;
onNodeHoverRef.current = onNodeHover;
fullDataRef.current = data;
nodeColorFnRef.current = nodeColorFn;
linkColorFnRef.current = linkColorFn;
// Transform and limit data - only limit nodes, show ALL links between visible nodes
const graphData = useMemo(() => {
let nodes = [...data.nodes];
// Limit nodes if needed
if (maxNodes && nodes.length > maxNodes) {
nodes = nodes.slice(0, maxNodes);
}
// Show ALL links between visible nodes (no random link limiting)
const nodeIds = new Set(nodes.map(n => n.id));
const links = data.links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links };
}, [data, maxNodes]);
// Convert to Cytoscape format
const cyElements = useMemo(() => {
const nodes = graphData.nodes.map(node => ({
data: {
id: node.id,
label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : (node.color || DEFAULT_NODE_COLOR),
size: nodeSizeFn ? nodeSizeFn(node) : (node.size || DEFAULT_NODE_SIZE),
originalNode: node,
},
}));
const edges = graphData.links.map((link, idx) => ({
data: {
id: `edge-${idx}`,
source: link.source,
target: link.target,
color: linkColorFn ? linkColorFn(link) : (link.color || DEFAULT_LINK_COLOR),
width: linkWidthFn ? linkWidthFn(link) : (link.width || DEFAULT_LINK_WIDTH),
type: link.type,
entity: link.entity,
weight: link.weight,
originalLink: link,
},
}));
return [...nodes, ...edges];
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
// Initialize Cytoscape
useEffect(() => {
if (!containerRef.current) return;
// Handle empty data case
if (cyElements.length === 0) {
setIsLoading(false);
return;
}
setIsLoading(true);
// Theme-aware colors
const textColor = isDarkMode ? '#ffffff' : '#1f2937';
const textBgColor = isDarkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)';
const borderColor = isDarkMode ? '#ffffff' : '#374151';
const cy = cytoscape({
container: containerRef.current,
elements: cyElements,
style: [
{
selector: 'node',
style: {
'background-fill': 'radial-gradient',
'background-gradient-stop-colors': ['#0074d9', '#005bb5'],
'background-gradient-stop-positions': ['0%', '100%'],
'width': 'data(size)',
'height': 'data(size)',
'label': showLabels ? 'data(label)' : '',
'color': textColor,
'text-valign': 'bottom',
'text-halign': 'center',
'font-size': '8px',
'font-weight': 500,
'text-margin-y': 3,
'text-wrap': 'wrap',
'text-max-width': '80px',
'text-background-color': textBgColor,
'text-background-opacity': 0.9,
'text-background-padding': '2px',
'text-background-shape': 'roundrectangle',
'border-width': 0,
'z-index': 0,
},
},
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#0074d9',
'border-opacity': 1,
},
},
{
selector: 'node:active',
style: {
'overlay-opacity': 0,
},
},
{
selector: 'edge',
style: {
'width': 'data(width)',
'line-color': 'data(color)',
'target-arrow-color': 'data(color)',
'curve-style': 'bezier',
'opacity': isDarkMode ? 0.5 : 0.6,
'z-index': 1,
},
},
{
selector: 'edge:selected',
style: {
'opacity': 1,
'width': 3,
},
},
// Dimmed state for non-selected elements
{
selector: '.dimmed',
style: {
'opacity': 0.15,
},
},
// Highlighted state for selected node and neighbors
{
selector: 'node.highlighted',
style: {
'opacity': 1,
'border-width': 3,
'border-color': '#0074d9',
'border-opacity': 1,
},
},
{
selector: 'edge.highlighted',
style: {
'opacity': 0.9,
'width': 2,
},
},
],
layout: {
name: 'cose',
animate: false,
randomize: true,
nodeRepulsion: () => 100000,
idealEdgeLength: () => 300,
edgeElasticity: () => 20,
nestingFactor: 0.1,
gravity: 0.01,
numIter: 2500,
coolingFactor: 0.95,
minTemp: 1.0,
nodeOverlap: 20,
nodeDimensionsIncludeLabels: true,
padding: 50,
} as any,
minZoom: 0.1,
maxZoom: 5,
wheelSensitivity: 0.3,
});
cyRef.current = cy;
// Event handlers
cy.on('tap', 'node', (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data('originalNode') as GraphNode;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
}
// Find ALL connected nodes from full data (not just visible ones)
const fullData = fullDataRef.current;
const clickedNodeId = originalNode.id;
// Find all links connected to this node from full data
const connectedLinks = fullData.links.filter(
l => l.source === clickedNodeId || l.target === clickedNodeId
);
// Find all connected node IDs
const connectedNodeIds = new Set<string>();
connectedLinks.forEach(l => {
connectedNodeIds.add(l.source);
connectedNodeIds.add(l.target);
});
// Add any missing nodes to the graph
const existingNodeIds = new Set(cy.nodes().map(n => n.id()));
const nodesToAdd: any[] = [];
const edgesToAdd: any[] = [];
connectedNodeIds.forEach(nodeId => {
if (!existingNodeIds.has(nodeId)) {
const nodeData = fullData.nodes.find(n => n.id === nodeId);
if (nodeData) {
nodesToAdd.push({
group: 'nodes',
data: {
id: nodeData.id,
label: nodeData.label || nodeData.id.substring(0, 8),
color: nodeColorFnRef.current ? nodeColorFnRef.current(nodeData) : (nodeData.color || DEFAULT_NODE_COLOR),
size: nodeData.size || DEFAULT_NODE_SIZE,
originalNode: nodeData,
isTemporary: true, // Mark as temporarily added
},
});
}
}
});
// Add missing edges
const existingEdgeIds = new Set(cy.edges().map(e => `${e.data('source')}-${e.data('target')}`));
connectedLinks.forEach((link, idx) => {
const edgeKey = `${link.source}-${link.target}`;
const reverseKey = `${link.target}-${link.source}`;
if (!existingEdgeIds.has(edgeKey) && !existingEdgeIds.has(reverseKey)) {
edgesToAdd.push({
group: 'edges',
data: {
id: `temp-edge-${idx}-${Date.now()}`,
source: link.source,
target: link.target,
color: linkColorFnRef.current ? linkColorFnRef.current(link) : (link.color || DEFAULT_LINK_COLOR),
width: link.width || DEFAULT_LINK_WIDTH,
type: link.type,
isTemporary: true,
},
});
}
});
// Add new elements to graph
if (nodesToAdd.length > 0 || edgesToAdd.length > 0) {
cy.add([...nodesToAdd, ...edgesToAdd]);
// Position new nodes near the clicked node
const clickedPos = node.position();
cy.nodes('[?isTemporary]').forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodesToAdd.length;
const radius = 150;
n.position({
x: clickedPos.x + radius * Math.cos(angle),
y: clickedPos.y + radius * Math.sin(angle),
});
});
}
// Get all connected elements (including newly added)
const neighborhood = node.neighborhood().add(node);
// Dim all elements first
cy.elements().addClass('dimmed');
// Highlight the neighborhood
neighborhood.removeClass('dimmed');
neighborhood.addClass('highlighted');
// Center on the neighborhood without changing positions
cy.animate({
fit: { eles: neighborhood, padding: 50 },
}, { duration: 400 });
});
// Click on background to reset
cy.on('tap', (evt) => {
if (evt.target === cy) {
// Remove temporary nodes and edges
cy.elements('[?isTemporary]').remove();
cy.elements().removeClass('dimmed highlighted');
cy.animate({
fit: { eles: cy.elements(), padding: 50 },
}, { duration: 400 });
}
});
cy.on('mouseover', 'node', (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data('originalNode') as GraphNode;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
containerRef.current!.style.cursor = 'pointer';
});
cy.on('mouseout', 'node', () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
containerRef.current!.style.cursor = 'default';
});
// Edge hover handlers
cy.on('mouseover', 'edge', (evt) => {
const edge = evt.target;
const originalLink = edge.data('originalLink') as GraphLink;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
containerRef.current!.style.cursor = 'pointer';
});
cy.on('mouseout', 'edge', () => {
setHoveredLink(null);
setLinkTooltipPos(null);
containerRef.current!.style.cursor = 'default';
});
// Run layout
cy.layout({
name: 'cose',
animate: false,
randomize: true,
nodeRepulsion: () => 100000,
idealEdgeLength: () => 300,
edgeElasticity: () => 20,
nestingFactor: 0.1,
gravity: 0.01,
numIter: 2500,
coolingFactor: 0.95,
minTemp: 1.0,
nodeOverlap: 20,
nodeDimensionsIncludeLabels: true,
padding: 50,
} as any).run();
// Fit to viewport
cy.fit(undefined, 50);
setIsLoading(false);
return () => {
cy.destroy();
};
}, [cyElements, showLabels, isDarkMode]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (cyRef.current) {
cyRef.current.resize();
cyRef.current.fit(undefined, 50);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div className="relative w-full rounded-lg overflow-hidden border border-border" style={{ height }}>
{/* Loading state */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
</div>
)}
{/* Cytoscape container */}
<div
ref={containerRef}
className="w-full h-full"
style={{
background: isDarkMode
? 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)'
: 'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)',
backgroundSize: '20px 20px',
backgroundColor: isDarkMode ? '#0f1419' : '#f8fafc',
}}
/>
{/* Empty state */}
{!isLoading && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">No memories to display</p>
</div>
</div>
)}
{/* Link hover tooltip */}
{hoveredLink && linkTooltipPos && (
<div
className="absolute z-30 pointer-events-none"
style={{
left: linkTooltipPos.x,
top: linkTooltipPos.y,
transform: 'translate(-50%, -100%) translateY(-8px)',
}}
>
<div className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
isDarkMode ? 'bg-gray-800 text-white' : 'bg-white text-gray-900 border border-gray-200'
}`}>
<div className="font-medium capitalize mb-1">
{(() => {
const type = hoveredLink.type || 'semantic';
if (['causes', 'caused_by', 'enables', 'prevents'].includes(type)) {
return `Causal (${type.replace('_', ' ')})`;
}
return `${type} link`;
})()}
</div>
{hoveredLink.entity && (
<div className="text-xs opacity-80">
Entity: <span className="font-medium">{hoveredLink.entity}</span>
</div>
)}
{hoveredLink.weight !== undefined && (
<div className="text-xs opacity-80">
Weight: <span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
</div>
)}
</div>
</div>
)}
{/* Controls hint */}
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
Drag to pan Scroll to zoom Click node to focus
</div>
</div>
);
}
// ============================================================================
// Utility Functions
// ============================================================================
export function convertHindsightGraphData(hindsightData: {
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
edges?: Array<{ data: { source: string; target: string; color?: string; lineStyle?: string; linkType?: string; entityName?: string; weight?: number; similarity?: number } }>;
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
}): GraphData {
const nodes: GraphNode[] = (hindsightData.nodes || []).map(n => {
const tableRow = hindsightData.table_rows?.find(r => r.id === n.data.id);
// Use memory text as label, truncated to ~40 chars
let label = n.data.label;
if (!label && tableRow?.text) {
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + '...' : tableRow.text;
}
if (!label) {
label = n.data.id.substring(0, 8);
}
return {
id: n.data.id,
label,
color: n.data.color,
metadata: tableRow,
};
});
const links: GraphLink[] = (hindsightData.edges || []).map(e => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
// Use linkType directly from API, fallback to lineStyle check, default to semantic
type: e.data.linkType || (e.data.lineStyle === 'dashed' ? 'temporal' : 'semantic'),
entity: e.data.entityName, // API returns entityName
weight: e.data.weight ?? e.data.similarity,
}));
return { nodes, links };
}
@@ -64,13 +64,12 @@ export function MemoryDetailPanel({
<p className="text-sm text-muted-foreground mt-1">Full memory content and metadata</p>
</div>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={onClose}
className="h-9 px-3 gap-2"
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
Close
<X className="h-5 w-5" />
</Button>
</div>
File diff suppressed because it is too large Load Diff
@@ -15,16 +15,16 @@ interface SidebarProps {
export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
const { currentBank } = useBank();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
if (!currentBank) {
return null;
}
const navItems = [
{ id: 'data' as NavItem, label: 'Memories', icon: Database },
{ id: 'recall' as NavItem, label: 'Recall', icon: Search },
{ id: 'reflect' as NavItem, label: 'Reflect', icon: Sparkles },
{ id: 'data' as NavItem, label: 'Memories', icon: Database },
{ id: 'documents' as NavItem, label: 'Documents', icon: FileText },
{ id: 'entities' as NavItem, label: 'Entities', icon: Users },
{ id: 'profile' as NavItem, label: 'Memory Bank', icon: Box },
@@ -35,24 +35,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
'bg-card border-r border-border flex flex-col transition-all duration-300',
isCollapsed ? 'w-16' : 'w-64'
)}>
<div className="p-4 border-b border-border flex items-center justify-between">
{!isCollapsed && (
<h2 className="text-lg font-semibold text-card-foreground">Hindsight</h2>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 rounded-lg hover:bg-accent transition-colors ml-auto"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronLeft className="w-5 h-5" />
)}
</button>
</div>
<nav className="flex-1 p-3">
<nav className="flex-1 p-3 pt-4">
<ul className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
@@ -75,7 +58,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
? 'bg-primary-gradient text-white shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
isCollapsed && 'justify-center px-0'
)}
@@ -89,6 +72,27 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
})}
</ul>
</nav>
{/* Collapse/Expand button at bottom */}
<div className="p-3 border-t border-border">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
'w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<>
<ChevronLeft className="w-5 h-5" />
<span>Collapse</span>
</>
)}
</button>
</div>
</aside>
);
}
@@ -9,7 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary-gradient text-white hover:opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,46 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme') as Theme | null;
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = saved || (systemPrefersDark ? 'dark' : 'light');
setTheme(initialTheme);
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.classList.toggle('dark', newTheme === 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
@@ -317,7 +317,7 @@ If it's correct, set correct=true.
response_format=JudgeResponse,
scope="judge",
temperature=0,
max_tokens=4096
max_completion_tokens=4096
)
return judgement.correct, judgement.reasoning
@@ -398,7 +398,7 @@ Answer:
],
response_format=QuestionAnswer,
scope="memory",
max_tokens=32768,
max_completion_tokens=32768,
)
reasoning_text = answer_obj.reasoning or ""
if reasoning_text:
@@ -31,10 +31,10 @@ API available at http://localhost:8888
export OPENAI_API_KEY=sk-xxx
docker run -it -p 8888:8888 -p 9999:9999 \
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
ghcr.io/vectorize-io/hindsight:latest
```
- **API**: http://localhost:8888
@@ -100,7 +100,7 @@ await client.reflect('my-bank', 'Tell me about Alice');
<TabItem value="cli" label="CLI">
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```
```bash
@@ -37,11 +37,12 @@ See [Models](./models) for detailed comparison and configuration.
Run everything in one container with embedded PostgreSQL:
```bash
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
ghcr.io/vectorize-io/hindsight
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API Server**: http://localhost:8888
+11 -10
View File
@@ -6,6 +6,17 @@ sidebar_position: 4
When you call `reflect()`, Hindsight doesn't just retrieve facts — it **reasons** about them through the lens of the bank's unique disposition, forming new opinions and generating contextual responses.
```mermaid
graph LR
A[Query] --> B[Recall Memories]
B --> C[Load Disposition]
C --> D[Reason]
D --> E[Form Opinions]
E --> F[Response]
```
---
## Why Reflect?
Most AI systems can retrieve facts, but they can't **reason** about them in a consistent way. Every response is generated fresh without a stable perspective or evolving beliefs.
@@ -41,16 +52,6 @@ With reflect:
---
## The Reflect Process
1. **Recall** relevant memories based on the query
2. **Load** the bank's disposition traits and background
3. **Reason** about the memories through the disposition lens
4. **Form** new opinions with confidence scores
5. **Return** response, sources, and any new beliefs
---
## Disposition Traits
When you create a memory bank, you can configure its disposition using three traits. These traits influence how the bank interprets information and forms opinions during `reflect()`:
+6 -10
View File
@@ -8,16 +8,12 @@ When you call `retain()`, Hindsight transforms conversations and documents into
## What Retain Does
```
Your Content
Extract Rich Facts
Identify Entities
Build Connections
Searchable Memory Bank
```mermaid
graph LR
A[Your Content] --> B[Extract Facts]
B --> C[Identify Entities]
C --> D[Build Connections]
D --> E[Memory Bank]
```
---
+18 -13
View File
@@ -6,6 +6,24 @@ sidebar_position: 3
When you call `recall()`, Hindsight uses multiple search strategies in parallel to find the most relevant memories, regardless of how you phrase your query.
```mermaid
graph LR
Q[Query] --> S[Semantic]
Q --> K[Keyword]
Q --> G[Graph]
Q --> T[Temporal]
S --> RRF[RRF Fusion]
K --> RRF
G --> RRF
T --> RRF
RRF --> CE[Cross-Encoder]
CE --> R[Results]
```
---
## The Challenge of Memory Recall
Different queries need different search approaches:
@@ -117,19 +135,6 @@ This gives your agent richer context while maintaining precise control over tota
---
## How Recall Works
When you call `recall(query, bank_id)`:
1. **Parse** → Detect temporal expressions, understand intent
2. **Search** → Run 4 strategies in parallel
3. **Fuse** → Combine results, prioritizing consensus
4. **Rerank** → Neural reranking for final relevance
5. **Filter** → Select top memories within token budget
6. **Return** → Ranked, relevant memories
---
## Tuning Recall: Quality vs Latency
Different use cases require different trade-offs between **recall quality** and **response speed**. Two parameters control this:
+22 -2
View File
@@ -9,7 +9,7 @@ The Hindsight CLI provides command-line access to memory operations and bank man
## Installation
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```
## Configuration
@@ -179,12 +179,32 @@ hindsight memory recall <bank_id> "query" -o yaml
## Interactive Explorer
Launch the TUI explorer for visual navigation:
Launch the TUI explorer for visual navigation of your memory banks:
```bash
hindsight explore
```
The explorer provides an interactive terminal interface to:
- **Browse memory banks** — View all banks and their statistics
- **Search memories** — Run recall queries with real-time results
- **Inspect entities** — Explore the knowledge graph and entity relationships
- **View facts** — Browse world facts, experiences, and opinions
- **Navigate documents** — See source documents and their extracted memories
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `↑/↓` | Navigate items |
| `Enter` | Select / Expand |
| `Tab` | Switch panels |
| `/` | Search |
| `q` | Quit |
<!-- Screenshot placeholder: explore command TUI -->
## Example Workflow
```bash
-138
View File
@@ -1,138 +0,0 @@
---
sidebar_position: 3
---
# LangGraph
Hindsight provides a `BaseStore` implementation for LangGraph's memory system.
## Installation
```bash
cd hindsight-langmem && uv pip install -e .
```
## Quick Start
```python
from hindsight_langmem import HindsightStore
# Create store
store = HindsightStore(
base_url="http://localhost:8888",
default_agent_id="my-agent",
)
# Store data
store.put(
namespace=("user", "preferences"),
key="language",
value={"language": "Python", "reason": "data science"}
)
# Retrieve data
item = store.get(namespace=("user", "preferences"), key="language")
print(item.value) # {"language": "Python", "reason": "data science"}
# Search
results = store.search(
namespace_prefix=("user",),
query="programming language",
limit=10
)
```
## How It Works
`HindsightStore` implements LangGraph's `BaseStore` interface:
- **Namespaces** map to Hindsight agent IDs (joined with `__`)
- **Keys** map to document IDs
- **Values** are stored as JSON in memory content
## BaseStore Interface
### put
Store an item:
```python
store.put(
namespace=("user", "session-123"),
key="preferences",
value={"theme": "dark", "language": "en"}
)
```
### get
Retrieve an item:
```python
item = store.get(namespace=("user", "session-123"), key="preferences")
if item:
print(item.value) # {"theme": "dark", "language": "en"}
print(item.created_at)
print(item.updated_at)
```
### search
Search within a namespace:
```python
results = store.search(
namespace_prefix=("user",),
query="theme preferences",
limit=10,
offset=0
)
for item in results:
print(f"{item.key}: {item.value}")
```
### delete
Delete an item:
```python
store.delete(namespace=("user", "session-123"), key="preferences")
```
## Async Support
All operations have async variants:
```python
await store.aput(namespace, key, value)
item = await store.aget(namespace, key)
results = await store.asearch(namespace_prefix, query)
await store.adelete(namespace, key)
```
## With LangGraph
```python
from langgraph.graph import StateGraph
from hindsight_langmem import HindsightStore
store = HindsightStore(base_url="http://localhost:8888")
# Use store in your graph
graph = StateGraph()
# ... configure graph with store
```
## Namespace Mapping
Namespaces are converted to Hindsight agent IDs:
| Namespace | bank ID |
|-----------|----------|
| `("user",)` | `user` |
| `("user", "session")` | `user__session` |
| `("app", "v1", "data")` | `app__v1__data` |
| `()` | `default_agent_id` |
Memory banks are created automatically if they don't exist.
-89
View File
@@ -1,89 +0,0 @@
---
sidebar_position: 4
---
# MCP Server
Model Context Protocol server for AI assistants like Claude Desktop.
## Setup
The MCP server is included in the Hindsight API. When running the API with MCP enabled, it exposes MCP tools at `/mcp/{bank_id}/sse`.
### Claude Desktop Configuration
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"hindsight": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:8888/mcp/my-bank-id/sse"]
}
}
}
```
Replace `my-bank-id` with your memory bank ID.
## Available Tools
### retain
Store a memory:
```json
{
"name": "retain",
"arguments": {
"content": "User prefers Python for data analysis",
"context": "preferences"
}
}
```
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | yes | Memory content to store |
| `context` | string | no | Category (default: 'general') |
### recall
Search memories:
```json
{
"name": "recall",
"arguments": {
"query": "What does the user do for work?"
}
}
```
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | yes | Natural language search query |
| `max_results` | integer | no | Max results (default: 10) |
## Usage Example
Once configured, Claude can use Hindsight naturally:
**User**: "Remember that I prefer morning meetings"
**Claude**: *Uses retain*
> "I've noted that you prefer morning meetings."
---
**User**: "What do you know about my preferences?"
**Claude**: *Uses recall*
> "Based on our conversations, you prefer morning meetings and like Python for data analysis."
-112
View File
@@ -1,112 +0,0 @@
---
sidebar_position: 2
---
# OpenAI
Drop-in replacement for the OpenAI Python client with automatic memory integration.
## Installation
```bash
cd hindsight-openai && uv pip install -e .
```
## Quick Start
```python
from hindsight_openai import configure, OpenAI
# Configure once
configure(
hindsight_api_url="http://localhost:8888",
agent_id="my-agent",
)
# Use OpenAI client normally
client = OpenAI(api_key="sk-...")
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What did we discuss about AI?"}]
)
```
## How It Works
The wrapper intercepts OpenAI calls:
1. **Before**: Retrieves relevant memories and injects as system message
2. **After**: Stores conversation to Hindsight
Your code works exactly as before, but now has memory.
## Configuration
```python
configure(
hindsight_api_url="http://localhost:8888", # Hindsight API
agent_id="my-agent", # Required
store_conversations=True, # Store conversations
inject_memories=True, # Inject memories into prompts
document_id="session-123", # Group by document
enabled=True, # Master switch
)
```
## Memory Injection
When enabled, memories are automatically injected:
```python
# Your code
messages = [{"role": "user", "content": "What trails did Alice recommend?"}]
# What gets sent to OpenAI
messages = [
{
"role": "system",
"content": "Relevant context:\n- Alice loves hiking in Yosemite\n- Alice recommended Half Dome trail"
},
{"role": "user", "content": "What trails did Alice recommend?"}
]
```
## Async Support
```python
from hindsight_openai import configure, AsyncOpenAI
configure(hindsight_api_url="http://localhost:8888", agent_id="my-agent")
client = AsyncOpenAI(api_key="sk-...")
response = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me about my preferences"}]
)
```
## Streaming
Fully supported:
```python
stream = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me a story"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
```
## Disable Temporarily
```python
from hindsight_openai import configure
configure(enabled=False) # Disable
configure(enabled=True) # Re-enable
```
+37 -21
View File
@@ -5,7 +5,7 @@ import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'Hindsight',
tagline: 'Entity-Aware Memory System for AI Agents',
favicon: 'img/favicon.ico',
favicon: 'img/favicon.png',
future: {
v4: true,
@@ -49,7 +49,7 @@ const config: Config = {
tagName: 'link',
attributes: {
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Nunito+Sans:wght@400;500;600;700;800&display=swap',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap',
media: 'print',
onload: "this.media='all'",
},
@@ -83,7 +83,7 @@ const config: Config = {
},
],
theme: {
primaryColor: '#0d9488',
primaryColor: '#0074d9',
sidebar: {
backgroundColor: '#09090b',
},
@@ -92,9 +92,9 @@ const config: Config = {
},
typography: {
fontSize: '15px',
fontFamily: "'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
headings: {
fontFamily: "'Avenir', 'Avenir Book', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamily: "'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
code: {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace",
@@ -121,10 +121,10 @@ const config: Config = {
respectPrefersColorScheme: true,
},
navbar: {
title: 'Hindsight',
logo: {
alt: 'Hindsight Logo',
src: 'img/logo.svg',
src: 'img/logo.png',
style: { height: '32px' },
},
items: [
{
@@ -164,8 +164,9 @@ const config: Config = {
},
{
href: 'https://github.com/vectorize-io/hindsight',
label: 'GitHub',
position: 'right',
className: 'header-github-link',
'aria-label': 'GitHub repository',
},
],
},
@@ -209,24 +210,39 @@ const config: Config = {
mermaid: {
theme: {
light: 'base',
dark: 'dark',
dark: 'base',
},
options: {
themeVariables: {
primaryColor: '#6366f1',
// Gradient start (#0074d9 blue) for nodes
primaryColor: '#0074d9',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4f46e5',
secondaryColor: '#f1f5f9',
secondaryTextColor: '#1e293b',
secondaryBorderColor: '#cbd5e1',
tertiaryColor: '#e0e7ff',
lineColor: '#94a3b8',
primaryBorderColor: '#005db0',
// Gradient end (#009296 teal) for edges/clusters
secondaryColor: '#009296',
secondaryTextColor: '#ffffff',
secondaryBorderColor: '#007a7d',
// Tertiary
tertiaryColor: '#e6f7f8',
tertiaryTextColor: '#1e293b',
// Lines and edges - gradient end color
lineColor: '#009296',
// Text
textColor: '#1e293b',
mainBkg: '#ffffff',
nodeBorder: '#4f46e5',
clusterBkg: '#f8fafc',
clusterBorder: '#e2e8f0',
fontFamily: 'system-ui, -apple-system, sans-serif',
// Node specific - gradient start
nodeBkg: '#0074d9',
nodeTextColor: '#ffffff',
nodeBorder: '#005db0',
// Main background
mainBkg: '#0074d9',
// Clusters/subgraphs - gradient end
clusterBkg: 'rgba(0, 146, 150, 0.08)',
clusterBorder: '#009296',
// Labels
edgeLabelBackground: 'transparent',
labelBackground: 'transparent',
// Font - Inter to match body text
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
},
},
+66 -1
View File
@@ -1046,6 +1046,47 @@
}
}
}
},
"delete": {
"tags": [
"Banks"
],
"summary": "Delete memory bank",
"description": "Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. This is a destructive operation that cannot be undone.",
"operationId": "delete_bank",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/memories": {
@@ -1494,6 +1535,28 @@
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"deleted_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Deleted Count"
}
},
"type": "object",
@@ -1503,7 +1566,9 @@
"title": "DeleteResponse",
"description": "Response model for delete operations.",
"example": {
"success": true
"success": true,
"message": "Deleted successfully",
"deleted_count": 10
}
},
"DispositionTraits": {
-22
View File
@@ -147,28 +147,6 @@ const sidebars: SidebarsConfig = {
},
],
},
{
type: 'category',
label: 'Integrations',
collapsible: false,
items: [
{
type: 'doc',
id: 'sdks/openai',
label: 'OpenAI',
},
{
type: 'doc',
id: 'sdks/langgraph',
label: 'LangGraph',
},
{
type: 'doc',
id: 'sdks/mcp',
label: 'MCP Server',
},
],
},
],
cookbookSidebar: [
{
+369 -53
View File
@@ -2,43 +2,49 @@
* Hindsight custom theme - Modern, clean style
*/
/* Primary colors - Teal/Cyan theme */
/* Primary colors - Blue to Teal gradient theme */
:root {
--ifm-color-primary: #0d9488;
--ifm-color-primary-dark: #0f766e;
--ifm-color-primary-darker: #115e59;
--ifm-color-primary-darkest: #134e4a;
--ifm-color-primary-light: #14b8a6;
--ifm-color-primary-lighter: #2dd4bf;
--ifm-color-primary-lightest: #5eead4;
/* Primary gradient */
--hindsight-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--hindsight-gradient-start: #0074d9;
--hindsight-gradient-end: #009296;
/* Fallback solid colors (midpoint of gradient) */
--ifm-color-primary: #0074d9;
--ifm-color-primary-dark: #0068c3;
--ifm-color-primary-darker: #005db0;
--ifm-color-primary-darkest: #004d91;
--ifm-color-primary-light: #1a85e0;
--ifm-color-primary-lighter: #3396e8;
--ifm-color-primary-lightest: #e6f3ff;
--ifm-code-font-size: 90%;
--docusaurus-highlighted-code-line-bg: rgba(13, 148, 136, 0.1);
--docusaurus-highlighted-code-line-bg: rgba(0, 116, 217, 0.1);
/* Typography - Avenir Book */
--ifm-font-family-base: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-heading-font-family: 'Avenir', 'Avenir Book', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-heading-font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-font-family-monospace: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
--ifm-font-weight-semibold: 600;
--ifm-font-size-base: 96%;
/* Spacing */
--ifm-spacing-horizontal: 1.5rem;
--ifm-navbar-height: 3.5rem;
--ifm-navbar-height: 4.5rem;
/* Borders */
--ifm-global-radius: 0.5rem;
}
[data-theme='dark'] {
--ifm-color-primary: #2dd4bf;
--ifm-color-primary-dark: #14b8a6;
--ifm-color-primary-darker: #0d9488;
--ifm-color-primary-darkest: #0f766e;
--ifm-color-primary-light: #5eead4;
--ifm-color-primary-lighter: #99f6e4;
--ifm-color-primary-lightest: #ccfbf1;
--docusaurus-highlighted-code-line-bg: rgba(45, 212, 191, 0.15);
--ifm-color-primary: #3396e8;
--ifm-color-primary-dark: #1a85e0;
--ifm-color-primary-darker: #0074d9;
--ifm-color-primary-darkest: #0068c3;
--ifm-color-primary-light: #66b3f0;
--ifm-color-primary-lighter: #99cff5;
--ifm-color-primary-lightest: rgba(51, 150, 232, 0.15);
--docusaurus-highlighted-code-line-bg: rgba(51, 150, 232, 0.15);
--ifm-background-color: #09090b;
--ifm-background-surface-color: #18181b;
@@ -47,6 +53,14 @@
--ifm-toc-border-color: #27272a;
}
/* Gradient text utility */
.gradient-text {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Navbar styling */
.navbar {
box-shadow: none;
@@ -54,9 +68,17 @@
padding: 0 1rem;
}
.navbar__logo {
margin-bottom: 0.5rem;
}
.navbar__title {
font-weight: 700;
font-size: 1.125rem;
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.navbar__items {
@@ -64,19 +86,40 @@
}
.navbar__link {
font-weight: 500;
font-size: 0.8125rem;
padding: 0.5rem 0.75rem;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 700;
font-size: 0.9rem;
padding: 0.625rem 1rem;
border-radius: 0.375rem;
transition: background-color 0.15s ease;
}
/* GitHub icon link */
.header-github-link::before {
content: '';
width: 24px;
height: 24px;
display: flex;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23666' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
}
[data-theme='dark'] .header-github-link::before {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23fff' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
}
.header-github-link:hover {
opacity: 0.7;
}
.navbar__link:hover {
background-color: var(--ifm-background-surface-color);
}
.navbar__link--active {
color: var(--ifm-color-primary);
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-theme='dark'] .navbar__link:hover {
@@ -89,7 +132,7 @@
}
.hero--primary {
background: linear-gradient(135deg, var(--ifm-color-primary-darkest) 0%, var(--ifm-color-primary-dark) 100%);
background: linear-gradient(135deg, var(--hindsight-gradient-start) 0%, var(--hindsight-gradient-end) 100%);
}
[data-theme='dark'] .hero--primary {
@@ -115,18 +158,26 @@
}
.button--primary {
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--hindsight-gradient);
border: none;
color: white;
}
.button--primary:hover {
background: var(--ifm-color-primary-dark);
border-color: var(--ifm-color-primary-dark);
background: linear-gradient(135deg, #005db0 0%, #007a7d 100%);
color: white;
}
.button--secondary {
background: transparent;
border: 2px solid currentColor;
border: 2px solid var(--hindsight-gradient-start);
color: var(--ifm-color-primary);
}
.button--secondary:hover {
background: var(--hindsight-gradient);
border-color: transparent;
color: white;
}
/* Sidebar */
@@ -137,13 +188,18 @@
}
.menu__link--active {
background: var(--ifm-color-primary-lightest);
color: var(--ifm-color-primary-darkest);
background: linear-gradient(135deg, rgba(0, 116, 217, 0.1) 0%, rgba(0, 146, 150, 0.1) 100%);
}
.menu__link--active .menu__link {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-theme='dark'] .menu__link--active {
background: rgba(45, 212, 191, 0.12);
color: var(--ifm-color-primary-light);
background: linear-gradient(135deg, rgba(0, 116, 217, 0.15) 0%, rgba(0, 146, 150, 0.15) 100%);
}
/* Non-collapsible category styling */
@@ -174,11 +230,16 @@
}
/* Code blocks */
pre,
pre code,
.prism-code {
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace) !important;
}
.prism-code {
border-radius: 0.5rem;
font-size: 0.875rem;
line-height: 1.6;
font-family: var(--ifm-font-family-monospace);
padding: 1rem !important;
}
@@ -187,7 +248,7 @@ code {
border-radius: 0.375rem;
padding: 0.2rem 0.45rem;
font-size: 0.875em;
font-family: var(--ifm-font-family-monospace);
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace) !important;
background-color: #f1f5f9;
color: #0f172a;
font-weight: 500;
@@ -200,7 +261,7 @@ code {
/* Don't apply inline styles to code inside pre blocks */
pre code {
background-color: transparent;
background-color: transparent !important;
border: none;
padding: 0;
font-size: inherit;
@@ -208,32 +269,36 @@ pre code {
font-weight: normal;
}
/* Code block container */
/* Code block container - single background source */
div[class*="codeBlockContainer"] {
border-radius: 0.5rem;
border: 1px solid var(--ifm-toc-border-color);
overflow: hidden;
background: #f8fafc;
background: #f8fafc !important;
}
[data-theme='dark'] div[class*="codeBlockContainer"] {
background: #0f172a;
background: #0f172a !important;
}
div[class*="codeBlockTitle"] {
font-size: 0.75rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--ifm-toc-border-color);
font-family: var(--ifm-font-family-monospace);
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace);
background: transparent !important;
}
/* Code block content area */
div[class*="codeBlockContent"] {
background: #f8fafc;
/* Code block content area - transparent to show container bg */
div[class*="codeBlockContent"],
div[class*="codeBlockContent"] pre,
div[class*="codeBlockContent"] .prism-code {
background: transparent !important;
}
[data-theme='dark'] div[class*="codeBlockContent"] {
background: #0f172a;
/* Prism token backgrounds */
.prism-code span {
background: transparent !important;
}
/* Cards/Features */
@@ -246,10 +311,21 @@ div[class*="codeBlockContent"] {
max-width: 100%;
}
article h1 {
/* Page title with gradient */
article h1,
.markdown h1,
header h1,
h1[class*="title"] {
font-size: 2.25rem;
font-weight: 800;
margin-bottom: 1.25rem;
background-image: linear-gradient(90deg, #0074d9, #009296) !important;
background-size: 100% !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
color: transparent !important;
display: inline-block;
}
article h2 {
@@ -258,7 +334,9 @@ article h2 {
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--ifm-toc-border-color);
border-bottom: 2px solid transparent;
border-image: var(--hindsight-gradient);
border-image-slice: 1;
}
article h3 {
@@ -272,10 +350,93 @@ article p {
line-height: 1.7;
}
/* Admonitions */
.admonition {
border-radius: 0.5rem;
border-left-width: 4px;
/* Links with gradient */
article a:not(.button):not([class*="hash-link"]) {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
font-weight: 500;
}
article a:not(.button):not([class*="hash-link"]):hover {
text-decoration: underline;
text-decoration-color: var(--hindsight-gradient-start);
}
/* Admonitions - gradient themed */
.theme-admonition,
[class*="admonition_"] {
border-radius: 0.5rem !important;
border-left: none !important;
border: none !important;
background: rgba(0, 116, 217, 0.05) !important;
position: relative !important;
overflow: hidden !important;
padding-left: 1.25rem !important;
}
/* Gradient left border using pseudo-element */
.theme-admonition::before,
[class*="admonition_"]::before {
content: '' !important;
position: absolute !important;
left: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 4px !important;
background: linear-gradient(180deg, #0074d9, #009296) !important;
border-radius: 0.5rem 0 0 0.5rem !important;
}
/* Admonition heading text - gradient */
[class*="admonitionHeading_"] {
background-image: linear-gradient(90deg, #0074d9, #009296) !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
color: transparent !important;
display: inline-flex !important;
align-items: center !important;
}
/* Admonition icon - gradient start color */
[class*="admonitionIcon_"] svg,
[class*="admonitionIcon_"] svg path {
fill: #0074d9 !important;
}
/* Different admonition types */
.alert--info[class*="admonition_"],
.theme-admonition-info {
background: rgba(0, 116, 217, 0.05) !important;
}
.alert--success[class*="admonition_"],
.theme-admonition-tip {
background: rgba(0, 146, 150, 0.05) !important;
}
.alert--warning[class*="admonition_"],
.theme-admonition-warning {
background: rgba(0, 116, 217, 0.08) !important;
}
.alert--secondary[class*="admonition_"],
.theme-admonition-note {
background: rgba(0, 131, 154, 0.05) !important;
}
/* Dark mode admonitions */
[data-theme='dark'] .theme-admonition,
[data-theme='dark'] [class*="admonition_"] {
background: rgba(0, 131, 154, 0.1) !important;
}
[data-theme='dark'] .theme-admonition::before,
[data-theme='dark'] [class*="admonition_"]::before {
background: linear-gradient(180deg, #3396e8, #00b4b8) !important;
}
/* Tables - Compact styling */
@@ -318,6 +479,14 @@ th {
padding: 0.5rem 1rem;
}
.tabs__item--active {
border-bottom-color: var(--hindsight-gradient-start);
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Redoc sidebar - expand all tags by default */
[class*="redoc-wrap"] [class*="menu-content"] ul {
@@ -329,6 +498,153 @@ th {
display: none !important;
}
/* Mermaid diagram styling - using high specificity selectors */
.mermaid svg[id^="mermaid"] {
background: transparent !important;
}
/* Node shapes - gradient start color (#0074d9 blue) */
.mermaid svg[id^="mermaid"] .node rect,
.mermaid svg[id^="mermaid"] .node circle,
.mermaid svg[id^="mermaid"] .node ellipse,
.mermaid svg[id^="mermaid"] .node polygon,
.mermaid svg[id^="mermaid"] .node path,
svg[id^="mermaid"] .node rect,
svg[id^="mermaid"] .node circle,
svg[id^="mermaid"] .node ellipse,
svg[id^="mermaid"] .node polygon,
svg[id^="mermaid"] .node path {
fill: #0074d9 !important;
stroke: #005db0 !important;
}
/* Node text - white on colored background, Inter font */
.mermaid svg[id^="mermaid"] .node .label,
.mermaid svg[id^="mermaid"] .nodeLabel,
.mermaid svg[id^="mermaid"] .node text,
.mermaid svg[id^="mermaid"] .node foreignObject div,
.mermaid svg[id^="mermaid"] .node foreignObject span,
.mermaid svg[id^="mermaid"] .node foreignObject p,
svg[id^="mermaid"] .node .label,
svg[id^="mermaid"] .nodeLabel,
svg[id^="mermaid"] .node text,
svg[id^="mermaid"] .node foreignObject div,
svg[id^="mermaid"] .node foreignObject span,
svg[id^="mermaid"] .node foreignObject p {
color: #ffffff !important;
fill: #ffffff !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.9375rem !important;
}
/* Edge/arrow lines - gradient end color (#009296 teal) */
.mermaid svg[id^="mermaid"] .edgePath .path,
.mermaid svg[id^="mermaid"] .flowchart-link,
svg[id^="mermaid"] .edgePath .path,
svg[id^="mermaid"] .flowchart-link {
stroke: #009296 !important;
}
/* Arrow heads - gradient end color */
.mermaid svg[id^="mermaid"] .marker path,
.mermaid svg[id^="mermaid"] .arrowheadPath,
.mermaid svg[id^="mermaid"] marker path,
svg[id^="mermaid"] .marker path,
svg[id^="mermaid"] .arrowheadPath,
svg[id^="mermaid"] marker path {
fill: #009296 !important;
stroke: #009296 !important;
}
/* Edge labels - transparent background, Inter font */
.mermaid svg[id^="mermaid"] .edgeLabel,
.mermaid svg[id^="mermaid"] .edgeLabel rect,
.mermaid svg[id^="mermaid"] .edgeLabel span,
.mermaid svg[id^="mermaid"] .labelBkg,
svg[id^="mermaid"] .edgeLabel,
svg[id^="mermaid"] .edgeLabel rect,
svg[id^="mermaid"] .edgeLabel span,
svg[id^="mermaid"] .labelBkg {
background: transparent !important;
background-color: transparent !important;
fill: transparent !important;
}
.mermaid svg[id^="mermaid"] .edgeLabel text,
.mermaid svg[id^="mermaid"] .edgeLabel span,
.mermaid svg[id^="mermaid"] .edgeLabel p,
svg[id^="mermaid"] .edgeLabel text,
svg[id^="mermaid"] .edgeLabel span,
svg[id^="mermaid"] .edgeLabel p {
color: var(--ifm-font-color-base) !important;
fill: var(--ifm-font-color-base) !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.875rem !important;
}
/* Cluster/subgraph boxes - gradient end color border (#009296) */
.mermaid svg[id^="mermaid"] .cluster rect,
svg[id^="mermaid"] .cluster rect {
fill: rgba(0, 146, 150, 0.08) !important;
stroke: #009296 !important;
stroke-width: 2px !important;
}
/* Cluster labels - Inter font */
.mermaid svg[id^="mermaid"] .cluster text,
.mermaid svg[id^="mermaid"] .cluster .nodeLabel,
.mermaid svg[id^="mermaid"] .cluster-label text,
.mermaid svg[id^="mermaid"] .cluster-label span,
.mermaid svg[id^="mermaid"] .cluster-label p,
svg[id^="mermaid"] .cluster text,
svg[id^="mermaid"] .cluster .nodeLabel,
svg[id^="mermaid"] .cluster-label text,
svg[id^="mermaid"] .cluster-label span,
svg[id^="mermaid"] .cluster-label p {
color: var(--ifm-font-color-base) !important;
fill: var(--ifm-font-color-base) !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.9375rem !important;
}
/* All mermaid text should use Inter */
.mermaid svg[id^="mermaid"] text,
.mermaid svg[id^="mermaid"] span,
.mermaid svg[id^="mermaid"] p,
svg[id^="mermaid"] text,
svg[id^="mermaid"] span,
svg[id^="mermaid"] p {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
}
/* Dark mode adjustments */
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel span,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel p,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel text,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel span,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel p {
color: #e2e8f0 !important;
fill: #e2e8f0 !important;
}
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label span,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label p,
[data-theme='dark'] svg[id^="mermaid"] .cluster text,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label text,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label span,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label p {
color: #e2e8f0 !important;
fill: #e2e8f0 !important;
}
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster rect,
[data-theme='dark'] svg[id^="mermaid"] .cluster rect {
fill: rgba(0, 146, 150, 0.15) !important;
}
/* List styling */
article ul, article ol {
font-size: 0.9375rem;
@@ -2,7 +2,7 @@
set -e
# Hindsight CLI installer
# Usage: curl -sSf https://your-domain.com/install.sh | sh
# Usage: curl -fsSL https://hindsight.vectorize.io/get-cli | bash
REPO_URL="https://github.com/vectorize-io/hindsight"
INSTALL_DIR="${HINDSIGHT_INSTALL_DIR:-$HOME/.local/bin}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7c3aed;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a78bfa;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
<circle cx="50" cy="35" r="12" fill="white" opacity="0.9"/>
<circle cx="35" cy="55" r="8" fill="white" opacity="0.7"/>
<circle cx="65" cy="55" r="8" fill="white" opacity="0.7"/>
<circle cx="50" cy="70" r="6" fill="white" opacity="0.5"/>
<line x1="50" y1="47" x2="35" y2="55" stroke="white" stroke-width="2" opacity="0.6"/>
<line x1="50" y1="47" x2="65" y2="55" stroke="white" stroke-width="2" opacity="0.6"/>
<line x1="35" y1="55" x2="50" y2="70" stroke="white" stroke-width="2" opacity="0.5"/>
<line x1="65" y1="55" x2="50" y2="70" stroke="white" stroke-width="2" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 968 B

File diff suppressed because it is too large Load Diff
+66 -1
View File
@@ -1046,6 +1046,47 @@
}
}
}
},
"delete": {
"tags": [
"Banks"
],
"summary": "Delete memory bank",
"description": "Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. This is a destructive operation that cannot be undone.",
"operationId": "delete_bank",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/memories": {
@@ -1494,6 +1535,28 @@
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"deleted_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Deleted Count"
}
},
"type": "object",
@@ -1503,7 +1566,9 @@
"title": "DeleteResponse",
"description": "Response model for delete operations.",
"example": {
"success": true
"success": true,
"message": "Deleted successfully",
"deleted_count": 10
}
},
"DispositionTraits": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+66 -1
View File
@@ -1046,6 +1046,47 @@
}
}
}
},
"delete": {
"tags": [
"Banks"
],
"summary": "Delete memory bank",
"description": "Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. This is a destructive operation that cannot be undone.",
"operationId": "delete_bank",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/memories": {
@@ -1494,6 +1535,28 @@
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"deleted_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Deleted Count"
}
},
"type": "object",
@@ -1503,7 +1566,9 @@
"title": "DeleteResponse",
"description": "Response model for delete operations.",
"example": {
"success": true
"success": true,
"message": "Deleted successfully",
"deleted_count": 10
}
},
"DispositionTraits": {
+1 -56
View File
@@ -3,51 +3,13 @@ set -e
cd "$(dirname "$0")/../.."
# Parse arguments
SERVER_ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--help|-h)
echo "Usage: $0 [--env local|dev] [uvicorn options...]"
echo ""
echo "Options:"
echo " --env local Use local environment (default)"
echo " --env dev Use dev environment"
echo ""
echo "Uvicorn options (passed to server):"
echo " --host HOST Host to bind to (default: 0.0.0.0)"
echo " --port PORT Port to bind to (default: 8888)"
echo " --reload Enable auto-reload on code changes"
echo " --workers WORKERS Number of worker processes (default: 1)"
echo " --log-level LEVEL Log level: critical/error/warning/info/debug/trace"
echo " --access-log Enable access log"
echo " --no-access-log Disable access log"
echo " --proxy-headers Enable X-Forwarded-Proto, X-Forwarded-For headers"
echo " --forwarded-allow-ips Comma separated list of IPs to trust"
echo " --ssl-keyfile FILE SSL key file"
echo " --ssl-certfile FILE SSL certificate file"
echo ""
echo "Example:"
echo " $0 --env dev --reload --port 8888 --log-level debug"
exit 0
;;
*)
# Pass all other arguments to the server
SERVER_ARGS+=("$1")
shift
;;
esac
done
# Source environment file
ENV_FILE=".env"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: Environment file $ENV_FILE not found at project root."
exit 1
fi
echo "📄 Loading environment from $ENV_FILE"
echo "Loading environment from $ENV_FILE"
echo ""
# Export all variables from env file
@@ -55,21 +17,4 @@ set -a
source "$ENV_FILE"
set +a
# Extract port from SERVER_ARGS if provided, otherwise use default
PORT=8888
for ((i=0; i<${#SERVER_ARGS[@]}; i++)); do
if [[ "${SERVER_ARGS[$i]}" == "--port" ]]; then
PORT="${SERVER_ARGS[$((i+1))]}"
break
fi
done
echo "Server will be available at: http://localhost:${PORT}"
echo ""
# Set default arguments if not provided
if [[ ${#SERVER_ARGS[@]} -eq 0 ]]; then
SERVER_ARGS=(--host 0.0.0.0 --port 8888)
fi
uv run hindsight-api "${SERVER_ARGS[@]}"
Generated
+4 -4
View File
@@ -1141,7 +1141,7 @@ wheels = [
[[package]]
name = "hindsight-all"
version = "0.1.0"
version = "0.1.2"
source = { editable = "hindsight" }
dependencies = [
{ name = "hindsight-api" },
@@ -1165,7 +1165,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-api"
version = "0.1.0"
version = "0.1.2"
source = { editable = "hindsight-api" }
dependencies = [
{ name = "alembic" },
@@ -1265,7 +1265,7 @@ dev = [
[[package]]
name = "hindsight-client"
version = "0.1.0"
version = "0.1.2"
source = { editable = "hindsight-clients/python" }
dependencies = [
{ name = "aiohttp" },
@@ -1297,7 +1297,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-dev"
version = "0.1.0"
version = "0.1.2"
source = { editable = "hindsight-dev" }
dependencies = [
{ name = "hindsight-api" },