diff --git a/AGENTS.md b/AGENTS.md index ea31f6d1..b4c7a9e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 1711b373..8a62e856 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docker/standalone/start-all.sh b/docker/standalone/start-all.sh index 3af1fb45..4e3a4267 100755 --- a/docker/standalone/start-all.sh +++ b/docker/standalone/start-all.sh @@ -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 diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index dbb4e504..40686c2a 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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, diff --git a/hindsight-api/hindsight_api/banner.py b/hindsight-api/hindsight_api/banner.py new file mode 100644 index 00000000..f1718b6e --- /dev/null +++ b/hindsight-api/hindsight_api/banner.py @@ -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() diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index f8d5b041..03d17f2f 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -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" diff --git a/hindsight-api/hindsight_api/engine/llm_wrapper.py b/hindsight-api/hindsight_api/engine/llm_wrapper.py index 589cb8c9..6710f0f0 100644 --- a/hindsight-api/hindsight_api/engine/llm_wrapper.py +++ b/hindsight-api/hindsight_api/engine/llm_wrapper.py @@ -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 diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index d13f35e4..464de44b 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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 = [] diff --git a/hindsight-api/hindsight_api/engine/retain/link_utils.py b/hindsight-api/hindsight_api/engine/retain/link_utils.py index c75c9130..72315de9 100644 --- a/hindsight-api/hindsight_api/engine/retain/link_utils.py +++ b/hindsight-api/hindsight_api/engine/retain/link_utils.py @@ -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: diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index b9e35677..5b52b658 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -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) diff --git a/hindsight-api/hindsight_api/pg0.py b/hindsight-api/hindsight_api/pg0.py index 8cc93ede..86cc20aa 100644 --- a/hindsight-api/hindsight_api/pg0.py +++ b/hindsight-api/hindsight_api/pg0.py @@ -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.""" diff --git a/hindsight-api/tests/test_llm_provider.py b/hindsight-api/tests/test_llm_provider.py new file mode 100644 index 00000000..65433878 --- /dev/null +++ b/hindsight-api/tests/test_llm_provider.py @@ -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" diff --git a/hindsight-api/tests/test_retain.py b/hindsight-api/tests/test_retain.py index e80ec619..cbbc570a 100644 --- a/hindsight-api/tests/test_retain.py +++ b/hindsight-api/tests/test_retain.py @@ -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) diff --git a/hindsight-cli/Cargo.toml b/hindsight-cli/Cargo.toml index fdaabb55..e8949984 100644 --- a/hindsight-cli/Cargo.toml +++ b/hindsight-cli/Cargo.toml @@ -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" diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index c1d346e4..0aa5ed45 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -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, } @@ -66,7 +66,13 @@ pub struct ApiClient { impl ApiClient { pub fn new(base_url: String) -> Result { 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 { + 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 diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs index 9d63016f..68c526e8 100644 --- a/hindsight-cli/src/commands/bank.rs +++ b/hindsight-cli/src/commands/bank.rs @@ -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) + } +} diff --git a/hindsight-cli/src/commands/document.rs b/hindsight-cli/src/commands/document.rs index 3fcf06cd..863a579f 100644 --- a/hindsight-cli/src/commands/document.rs +++ b/hindsight-cli/src/commands/document.rs @@ -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 { diff --git a/hindsight-cli/src/commands/entity.rs b/hindsight-cli/src/commands/entity.rs index 80a0c6f8..48f0ece7 100644 --- a/hindsight-cli/src/commands/entity.rs +++ b/hindsight-cli/src/commands/entity.rs @@ -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 { diff --git a/hindsight-cli/src/commands/explore.rs b/hindsight-cli/src/commands/explore.rs index e559ae55..e477d599 100644 --- a/hindsight-cli/src/commands/explore.rs +++ b/hindsight-cli/src/commands/explore.rs @@ -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, String>), + Reflect(Result), +} + /// 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>, } 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(terminal: &mut Terminal, 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(terminal: &mut Terminal, mut app: App) -> Result<()> { } } + // Check for query results from background thread + app.check_query_result(); + // Auto-refresh check app.do_auto_refresh()?; } diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 6f4610c7..600a3295 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -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 { diff --git a/hindsight-cli/src/commands/operation.rs b/hindsight-cli/src/commands/operation.rs index bdd5ff78..241a6c02 100644 --- a/hindsight-cli/src/commands/operation.rs +++ b/hindsight-cli/src/commands/operation.rs @@ -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 { diff --git a/hindsight-cli/src/logo.ansi b/hindsight-cli/src/logo.ansi new file mode 100644 index 00000000..e9afd101 --- /dev/null +++ b/hindsight-cli/src/logo.ansi @@ -0,0 +1,5 @@ + β–„β–„ β–„β–„ + β–„ β–€β–„ β–„β–„β–„ β–„β–€ β–„ +β–€β–€β–„β–„β–„β–„β–€β–€β–€β–„β–„β–„β–„β–€β–€ + β–„β–„β–„ β–„ β–„β–„β–„ + β–„β–€ β–€β–€β–„β–„β–„β–€ β–€β–„ diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index 1a37838b..5c1a6035 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -34,6 +34,7 @@ impl From 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 { diff --git a/hindsight-cli/src/ui.rs b/hindsight-cli/src/ui.rs index 615afa61..5fa43ae3 100644 --- a/hindsight-cli/src/ui.rs +++ b/hindsight-cli/src/ui.rs @@ -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 = 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) { - 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, + handle: Option>, +} + +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 = 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 { - 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 { 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!(); diff --git a/hindsight-control-plane/package-lock.json b/hindsight-control-plane/package-lock.json index 5d1e33e7..a65578fa 100644 --- a/hindsight-control-plane/package-lock.json +++ b/hindsight-control-plane/package-lock.json @@ -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", diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index d362e91d..c6c3c4f7 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -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" } } diff --git a/hindsight-control-plane/public/favicon.png b/hindsight-control-plane/public/favicon.png new file mode 100644 index 00000000..8414297f Binary files /dev/null and b/hindsight-control-plane/public/favicon.png differ diff --git a/hindsight-control-plane/public/logo.png b/hindsight-control-plane/public/logo.png new file mode 100644 index 00000000..0dac3a44 Binary files /dev/null and b/hindsight-control-plane/public/logo.png differ diff --git a/hindsight-control-plane/src/app/globals.css b/hindsight-control-plane/src/app/globals.css index 908c3b29..e41afbd5 100644 --- a/hindsight-control-plane/src/app/globals.css +++ b/hindsight-control-plane/src/app/globals.css @@ -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; } \ No newline at end of file diff --git a/hindsight-control-plane/src/app/layout.tsx b/hindsight-control-plane/src/app/layout.tsx index a950da1c..01ae1415 100644 --- a/hindsight-control-plane/src/app/layout.tsx +++ b/hindsight-control-plane/src/app/layout.tsx @@ -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 ( - + - - {children} - + + + {children} + + ); diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx index 9acc80e0..76b3aa60 100644 --- a/hindsight-control-plane/src/components/bank-selector.tsx +++ b/hindsight-control-plane/src/components/bank-selector.tsx @@ -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 ( -
-
- Memory Bank: +
+
+ {/* Logo */} + Hindsight + + {/* Separator */} +
+ + {/* Memory Bank Selector */} - + - + {sortedBanks.length > 0 && ( + + )} - No memory bank found. + No memory banks yet. {sortedBanks.map((bank) => ( + {/* Footer: Create new bank */} +
+ +
- + {/* Separator */} +
+ {/* Add Document Button */} {currentBank && ( )} + {/* Spacer */} +
+ + {/* GitHub Link */} + + + GitHub + + + {/* Separator */} +
+ + {/* Dark Mode Toggle */} + + @@ -333,17 +383,32 @@ function BankSelectorInner() { export function BankSelector() { return ( -
- Memory Bank: +
+
+ Hindsight +
+
+ + + GitHub + +
+
}> diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 6d465334..503de63a 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -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('graph'); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); - const [nodeLimit, setNodeLimit] = useState(50); - const [layout, setLayout] = useState('circle'); const [searchQuery, setSearchQuery] = useState(''); const [copiedId, setCopiedId] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedGraphNode, setSelectedGraphNode] = useState(null); const [selectedTableMemory, setSelectedTableMemory] = useState(null); const itemsPerPage = 100; - const cyRef = useRef(null); - const containerRef = useRef(null); + + // Graph controls state + const [showLabels, setShowLabels] = useState(true); + const [maxNodes, setMaxNodes] = useState(50); + const [showControlPanel, setShowControlPanel] = useState(true); + const [visibleLinkTypes, setVisibleLinkTypes] = useState>(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 = {}; + 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) {
) : data ? ( <> + {/* Always visible filter */} +
+ setSearchQuery(e.target.value)} + placeholder="Filter memories by text..." + className="max-w-md" + /> +
+
- {data.total_units} total memories + {searchQuery ? `${filteredTableRows.length} of ${data.total_units} memories` : `${data.total_units} total memories`}
+ + {/* Right Panel - Legend/Controls OR Memory Details */} +
+
+ {selectedGraphNode ? ( + /* Memory Detail View */ + setSelectedGraphNode(null)} + inPanel + /> + ) : ( + /* Legend & Controls View */ +
+ {/* Legend & Stats */} +
+

Graph

+
+ {/* Nodes */} +
+
+
+ Nodes +
+ + {Math.min(maxNodes ?? graph2DData.nodes.length, graph2DData.nodes.length)}/{graph2DData.nodes.length} + +
+ +
Links ({linkStats.total}) Β· click to filter
+ + + + + {Object.entries(linkStats.otherTypes || {}).map(([type, count]) => ( +
+ {type} + {count as number} +
+ ))} +
+
+ +
+ + {/* Controls Section */} +
+

Display

+
+
+ + +
+
+
+ +
+ + {/* Limits Section */} +
+

Performance

+
+
+
+ + + {maxNodes ?? 'All'} / {graph2DData.nodes.length} + +
+ setMaxNodes(v >= graph2DData.nodes.length ? undefined : v)} + className="w-full" + /> +
+

+ All links between visible nodes are shown. +

+
+
+ +
+ + {/* Hint */} +
+ Click a node to see details +
+
+ )}
- )} +
)} {viewMode === 'table' && (
-
- setSearchQuery(e.target.value)} - placeholder="Search memories (text, context, ID)..." - className="max-w-2xl" - /> -
-
- {data.table_rows && data.table_rows.length > 0 ? ( +
+ {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 ( <>
- +
- ID - Text - Context - Occurred - Mentioned - Actions + Memory + Entities + Occurred + Mentioned + {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' : '' }`} > - - {row.id?.substring(0, 8)}... + +
{row.text}
+ {row.context && ( +
{row.context}
+ )}
- -
{row.text}
- {row.entities && ( -
- {row.entities.split(', ').slice(0, 3).map((entity: string, i: number) => ( - + + {row.entities ? ( +
+ {row.entities.split(', ').slice(0, 2).map((entity: string, i: number) => ( + {entity} ))} - {row.entities.split(', ').length > 3 && ( + {row.entities.split(', ').length > 2 && ( - +{row.entities.split(', ').length - 3} + +{row.entities.split(', ').length - 2} )}
+ ) : ( + - )}
- - {row.context || '-'} + + {occurredDisplay || -} - - {occurredDisplay ? ( - - - {occurredDisplay} - - ) : '-'} + + {mentionedDisplay || -} - - {mentionedDisplay ? ( - - - {mentionedDisplay} - - ) : '-'} - - + - + {currentPage} / {totalPages}
@@ -499,7 +591,7 @@ export function DataView({ factType }: DataViewProps) { })() ) : (
- {data.table_rows ? 'No memories match your search' : 'No memories found'} + {data.table_rows?.length > 0 ? 'No memories match your filter' : 'No memories found'}
)} @@ -519,7 +611,7 @@ export function DataView({ factType }: DataViewProps) { )} {viewMode === 'timeline' && ( - + )} ) : ( @@ -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(null); const [granularity, setGranularity] = useState('month'); const [currentIndex, setCurrentIndex] = useState(0); const timelineRef = useRef(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 ( -
+
{/* Timeline */} -
+
{/* Controls */}
@@ -853,7 +936,7 @@ function TimelineView({ data }: { data: any }) { {item.entities && (
{item.entities.split(', ').slice(0, 3).map((entity: string, i: number) => ( - + {entity} ))} diff --git a/hindsight-control-plane/src/components/graph-2d.tsx b/hindsight-control-plane/src/components/graph-2d.tsx new file mode 100644 index 00000000..3bcdd1c5 --- /dev/null +++ b/hindsight-control-plane/src/components/graph-2d.tsx @@ -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; +} + +export interface GraphLink { + source: string; + target: string; + color?: string; + width?: number; + type?: string; + entity?: string; + weight?: number; + metadata?: Record; +} + +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(null); + const cyRef = useRef(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [hoveredLink, setHoveredLink] = useState(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(); + 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 ( +
+ {/* Loading state */} + {isLoading && ( +
+
+
+

Loading graph...

+
+
+ )} + + {/* Cytoscape container */} +
+ + {/* Empty state */} + {!isLoading && graphData.nodes.length === 0 && ( +
+
+

No memories to display

+
+
+ )} + + {/* Link hover tooltip */} + {hoveredLink && linkTooltipPos && ( +
+
+
+ {(() => { + const type = hoveredLink.type || 'semantic'; + if (['causes', 'caused_by', 'enables', 'prevents'].includes(type)) { + return `Causal (${type.replace('_', ' ')})`; + } + return `${type} link`; + })()} +
+ {hoveredLink.entity && ( +
+ Entity: {hoveredLink.entity} +
+ )} + {hoveredLink.weight !== undefined && ( +
+ Weight: {hoveredLink.weight.toFixed(3)} +
+ )} +
+
+ )} + + {/* Controls hint */} +
+ Drag to pan β€’ Scroll to zoom β€’ Click node to focus +
+
+ ); +} + +// ============================================================================ +// 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 }; +} diff --git a/hindsight-control-plane/src/components/memory-detail-panel.tsx b/hindsight-control-plane/src/components/memory-detail-panel.tsx index 2de73023..cc3c9791 100644 --- a/hindsight-control-plane/src/components/memory-detail-panel.tsx +++ b/hindsight-control-plane/src/components/memory-detail-panel.tsx @@ -64,13 +64,12 @@ export function MemoryDetailPanel({

Full memory content and metadata

diff --git a/hindsight-control-plane/src/components/search-debug-view.tsx b/hindsight-control-plane/src/components/search-debug-view.tsx index ea0ca083..7137e236 100644 --- a/hindsight-control-plane/src/components/search-debug-view.tsx +++ b/hindsight-control-plane/src/components/search-debug-view.tsx @@ -7,838 +7,407 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Info } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Search, Clock, Zap, ChevronRight, Database, FileText, Users } from 'lucide-react'; import JsonView from 'react18-json-view'; import 'react18-json-view/src/style.css'; import { MemoryDetailPanel } from './memory-detail-panel'; -type Phase = 'retrieval' | 'rrf' | 'rerank' | 'final' | 'json'; -type RetrievalMethod = 'semantic' | 'bm25' | 'graph' | 'temporal'; type FactType = 'world' | 'experience' | 'opinion'; - type Budget = 'low' | 'mid' | 'high'; - -// Helper component for column headers with tooltips -const ColumnHeader = ({ label, tooltip }: { label: string; tooltip: string }) => ( -
- {label} - - - - - - {tooltip} - - -
-); - -interface SearchPane { - id: number; - query: string; - factTypes: FactType[]; - budget: Budget; - maxTokens: number; - queryDate: string; - includeChunks: boolean; - includeEntities: boolean; - results: any[] | null; - entities: any[] | null; - chunks: any[] | null; - trace: any | null; - loading: boolean; - currentPhase: Phase; - currentRetrievalMethod: RetrievalMethod; - currentRetrievalFactType: FactType | null; - showRawJson: boolean; -} +type ViewMode = 'results' | 'trace' | 'json'; export function SearchDebugView() { const { currentBank } = useBank(); - const [panes, setPanes] = useState([ - { - id: 1, - query: '', - factTypes: ['world'], - budget: 'mid', - maxTokens: 4096, - queryDate: '', - includeChunks: false, - includeEntities: false, - results: null, - entities: null, - chunks: null, - trace: null, - loading: false, - currentPhase: 'retrieval', - currentRetrievalMethod: 'semantic', - currentRetrievalFactType: null, - showRawJson: false, - }, - ]); - const [nextPaneId, setNextPaneId] = useState(2); + + // Query state + const [query, setQuery] = useState(''); + const [factTypes, setFactTypes] = useState(['world']); + const [budget, setBudget] = useState('mid'); + const [maxTokens, setMaxTokens] = useState(4096); + const [queryDate, setQueryDate] = useState(''); + const [includeChunks, setIncludeChunks] = useState(false); + const [includeEntities, setIncludeEntities] = useState(false); + + // Results state + const [results, setResults] = useState(null); + const [entities, setEntities] = useState(null); + const [chunks, setChunks] = useState(null); + const [trace, setTrace] = useState(null); + const [loading, setLoading] = useState(false); + const [viewMode, setViewMode] = useState('results'); const [selectedMemory, setSelectedMemory] = useState(null); - const addPane = () => { - setPanes([ - ...panes, - { - id: nextPaneId, - query: '', - factTypes: ['world'], - budget: 'mid', - maxTokens: 4096, - queryDate: '', - includeChunks: false, - includeEntities: false, - results: null, - entities: null, - chunks: null, - trace: null, - loading: false, - currentPhase: 'retrieval', - currentRetrievalMethod: 'semantic', - currentRetrievalFactType: null, - showRawJson: false, - }, - ]); - setNextPaneId(nextPaneId + 1); - }; - - const removePane = (id: number) => { - if (panes.length > 1) { - setPanes(panes.filter((p) => p.id !== id)); - } - }; - - const updatePane = (id: number, updates: Partial) => { - setPanes(panes.map((p) => (p.id === id ? { ...p, ...updates } : p))); - }; - - const runSearch = async (paneId: number) => { + const runSearch = async () => { if (!currentBank) { alert('Please select a memory bank first'); return; } - const pane = panes.find((p) => p.id === paneId); - if (!pane || !pane.query || pane.factTypes.length === 0) { - if (pane?.factTypes.length === 0) { + if (!query || factTypes.length === 0) { + if (factTypes.length === 0) { alert('Please select at least one fact type'); } return; } - updatePane(paneId, { loading: true }); + setLoading(true); try { - // Always pass fact types as array for consistent behavior const requestBody: any = { bank_id: currentBank, - query: pane.query, - types: pane.factTypes, - budget: pane.budget, - max_tokens: pane.maxTokens, + query: query, + types: factTypes, + budget: budget, + max_tokens: maxTokens, trace: true, include: { - entities: pane.includeEntities ? { max_tokens: 500 } : null, - chunks: pane.includeChunks ? { max_tokens: 8192 } : null + entities: includeEntities ? { max_tokens: 500 } : null, + chunks: includeChunks ? { max_tokens: 8192 } : null }, - ...(pane.queryDate && { query_timestamp: pane.queryDate }) + ...(queryDate && { query_timestamp: queryDate }) }; const data: any = await client.recall(requestBody); - // Set default fact type for retrieval view (first selected fact type) - const defaultFactType = pane.currentRetrievalFactType || pane.factTypes[0]; - - updatePane(paneId, { - results: data.results || [], - entities: data.entities || null, - chunks: data.chunks || null, - trace: data.trace || null, - loading: false, - currentRetrievalFactType: defaultFactType, - currentPhase: 'final', - }); + setResults(data.results || []); + setEntities(data.entities || null); + setChunks(data.chunks || null); + setTrace(data.trace || null); + setViewMode('results'); } catch (error) { console.error('Error running search:', error); alert('Error running search: ' + (error as Error).message); - updatePane(paneId, { loading: false }); + } finally { + setLoading(false); } }; - const renderRetrievalResults = (pane: SearchPane) => { - if (!pane.trace || !pane.trace.retrieval_results) { - return
No retrieval data available
; - } - - // Filter by retrieval method - const methodData = pane.trace.retrieval_results.find( - (m: any) => m.method_name === pane.currentRetrievalMethod - ); - - if (!methodData || !methodData.results || methodData.results.length === 0) { - return ( -
- No results from this retrieval method -
- ); - } - - // Filter by fact type if multiple fact types are selected - let filteredResults = methodData.results; - if (pane.factTypes.length > 1 && pane.currentRetrievalFactType) { - filteredResults = methodData.results.filter( - (result: any) => result.fact_type === pane.currentRetrievalFactType - ); - } - - // Get method-specific score description - const scoreTooltips: Record = { - semantic: "Vector similarity score - measures conceptual similarity and paraphrasing (higher = more relevant)", - bm25: "BM25 exact match score - measures keyword/phrase overlap for names, technical terms (higher = more exact matches)", - graph: "Entity traversal score - measures connection strength through related entities and indirect relationships (higher = stronger connection)", - temporal: "Time-filtered relevance score - combines temporal proximity with semantic relevance for time-based queries (higher = better match in timeframe)" - }; - - const scoreTooltip = scoreTooltips[pane.currentRetrievalMethod] || "Relevance score from this retrieval method (higher = more relevant)"; - - return ( -
-

- {methodData.method_name.toUpperCase()} Retrieval - {pane.currentRetrievalFactType && pane.factTypes.length > 1 && ( - - {pane.currentRetrievalFactType} facts only - - )} - {' '}({filteredResults.length} results{pane.factTypes.length > 1 && ` of ${methodData.results.length}`}, {methodData.duration_seconds?.toFixed(3)}s) -

-
- - - - - {pane.factTypes.length > 1 && ( - - )} - - - - - {filteredResults.map((result: any, idx: number) => ( - setSelectedMemory(result)} - > - #{result.rank} - {result.text} - {pane.factTypes.length > 1 && ( - - - {result.fact_type || 'unknown'} - - - )} - {result.score?.toFixed(4)} - - ))} - -
-
- ); - }; - - const renderRRFMerge = (pane: SearchPane) => { - if (!pane.trace || !pane.trace.rrf_merged || pane.trace.rrf_merged.length === 0) { - return
No RRF merge data available
; - } - - return ( -
-

- RRF Merge Results ({pane.trace.rrf_merged.length} candidates) - {pane.factTypes.length > 1 && ( - - Unified across all fact types - - )} -

-

- Reciprocal Rank Fusion combines rankings from different retrieval methods - {pane.factTypes.length > 1 ? ' and fact types' : ''}. -

- - - - - - - - - - - {pane.trace.rrf_merged.map((result: any, idx: number) => { - // Try multiple possible field names for source ranks - const sourceRanksData = result.source_ranks || result.sourceRanks || result.ranks || {}; - const sourceRanks = Object.entries(sourceRanksData).length > 0 - ? Object.entries(sourceRanksData) - .map(([method, rank]) => `${method}: #${rank}`) - .join(', ') - : 'N/A'; - - return ( - setSelectedMemory(result)} - > - - #{result.final_rrf_rank || result.finalRrfRank || result.rank} - - {result.text} - {(result.rrf_score || result.rrfScore || result.score)?.toFixed(4)} - {sourceRanks} - - ); - })} - -
-
- ); - }; - - const renderReranking = (pane: SearchPane) => { - if (!pane.trace || !pane.trace.reranked || pane.trace.reranked.length === 0) { - return
No reranking data available
; - } - - return ( -
-

- Reranking Results ({pane.trace.reranked.length} results) - {pane.factTypes.length > 1 && ( - - Unified across all fact types - - )} -

-

- Cross-encoder reranker adjusts scores based on semantic relevance.{' '} - Highlight = rank improved - vs RRF -

- - - - - - - - - - - - - {pane.trace.reranked.map((result: any, idx: number) => { - const improved = result.rank_change > 0; - const rowBg = improved ? 'bg-secondary/20' : ''; - const changeDisplay = - result.rank_change > 0 - ? `↑${result.rank_change}` - : result.rank_change < 0 - ? `↓${Math.abs(result.rank_change)}` - : '='; - const changeColor = - result.rank_change > 0 - ? 'text-green-700' - : result.rank_change < 0 - ? 'text-red-700' - : 'text-gray-600'; - - // Format score components with better structure - const components = result.score_components || {}; - const crossEncoder = components.cross_encoder || components.crossEncoder || 0; - const heuristics = Object.entries(components) - .filter(([key]) => key !== 'cross_encoder' && key !== 'crossEncoder') - .map(([key, val]: [string, any]) => `${key}: ${val.toFixed(3)}`) - .join(', '); - - const componentDisplay = ( -
-
Cross-Encoder: {crossEncoder.toFixed(4)}
- {heuristics &&
Heuristics: {heuristics}
} -
- ); - - return ( - setSelectedMemory(result)} - > - #{result.rerank_rank} - #{result.rrf_rank} - - {changeDisplay} - - {result.text} - - {result.rerank_score?.toFixed(4)} - - {componentDisplay} - - ); - })} -
-
-
- ); - }; - - const renderFinalResults = (pane: SearchPane) => { - if (!pane.results || pane.results.length === 0) { - return
No final results
; - } - - const calculateRanks = (values: number[]) => { - const indexed = values.map((val, idx) => ({ idx, val })); - indexed.sort((a, b) => b.val - a.val); - const ranks = new Map(); - indexed.forEach((item, rank) => { - ranks.set(item.idx, rank + 1); - }); - return ranks; - }; - - const frequencies = pane.results.map((result: any) => { - const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id); - return visit ? visit.weights.frequency || 0 : 0; - }); - - const frequencyRanks = calculateRanks(frequencies); - - return ( -
-

- Final Results ({pane.results.length} memories) -

-

- Query: "{pane.trace?.query?.query_text || pane.query}" -

- - - - - - - - - - - - - {pane.results.map((result: any, idx: number) => { - const visit = pane.trace?.visits?.find((v: any) => v.node_id === result.id); - const finalScore = visit ? visit.weights.final_weight : result.score || 0; - - // Format temporal range with clearer display - let occurredDisplay: React.ReactNode = 'N/A'; - if (result.occurred_start && result.occurred_end) { - const start = new Date(result.occurred_start).toLocaleDateString(); - const end = new Date(result.occurred_end).toLocaleDateString(); - occurredDisplay = start === end ? start : ( -
-
Start: {start}
-
End: {end}
-
- ); - } else if (result.event_date) { - occurredDisplay = new Date(result.event_date).toLocaleDateString(); - } - - const mentionedDisplay = result.mentioned_at - ? new Date(result.mentioned_at).toLocaleDateString() - : 'N/A'; - - return ( - setSelectedMemory(result)} - > - #{idx + 1} - {result.text} - - {result.context || 'N/A'} - - - {occurredDisplay} - - - {mentionedDisplay} - - - {finalScore.toFixed(4)} - - - ); - })} -
-
-
+ const toggleFactType = (ft: FactType) => { + setFactTypes(prev => + prev.includes(ft) + ? prev.filter(t => t !== ft) + : [...prev, ft] ); }; if (!currentBank) { return ( -
-

No Bank Selected

-

Please select a memory bank from the dropdown above to use recall debug.

-
+ + + +

No Bank Selected

+

Select a memory bank to start recalling.

+
+
); } return ( -
-
-
- -
+
+ {/* Search Input */} + + +
+
+ + setQuery(e.target.value)} + placeholder="What would you like to recall?" + className="pl-10 h-12 text-lg" + onKeyDown={(e) => e.key === 'Enter' && runSearch()} + /> +
+ +
-
- {panes.map((pane) => ( -
- {/* Header */} -
- Recall Trace #{pane.id} - {panes.length > 1 && ( - - )} + {/* Filters */} +
+ {/* Fact Types */} +
+ Types: +
+ {(['world', 'experience', 'opinion'] as FactType[]).map((ft) => ( + + ))} +
- {/* Recall Controls */} -
-
- {/* Query */} -
-
- - updatePane(pane.id, { query: e.target.value })} - placeholder="Enter recall query..." - onKeyDown={(e) => e.key === 'Enter' && runSearch(pane.id)} - /> -
- -
+
- {/* Parameters Grid */} -
+ {/* Budget */} +
+ + +
+ + {/* Max Tokens */} +
+ Tokens: + setMaxTokens(parseInt(e.target.value))} + className="w-24 h-8" + /> +
+ + {/* Query Date */} +
+ + setQueryDate(e.target.value)} + className="h-8" + placeholder="Query date" + /> +
+ +
+ + {/* Include options */} +
+ + +
+
+ + + + {/* Results */} + {loading && ( + + +
+

Searching memories...

+ + + )} + + {!loading && results && ( +
+ {/* Summary Stats */} + {trace?.summary && ( +
+
+ Results: + {results.length} +
+
+ Duration: + {trace.summary.total_duration_seconds?.toFixed(2)}s +
+
+ Nodes visited: + {trace.summary.total_nodes_visited} +
+ +
+ + {/* View Mode Tabs */} +
+ {(['results', 'trace', 'json'] as ViewMode[]).map((mode) => ( + + ))} +
+
+ )} + + {/* Results View */} + {viewMode === 'results' && ( +
+ {results.length === 0 ? ( + + + +

No memories found for this query.

+
+
+ ) : ( + results.map((result: any, idx: number) => { + const visit = trace?.visits?.find((v: any) => v.node_id === result.id); + const score = visit ? visit.weights.final_weight : result.score || 0; + + return ( + setSelectedMemory(result)} + > + +
+
+ {idx + 1} +
+
+

{result.text}

+
+ {result.type || 'world'} + {result.context && ( + {result.context} + )} + {result.occurred_start && ( + {new Date(result.occurred_start).toLocaleDateString()} + )} +
+
+
+
{score.toFixed(3)}
+
score
+
+ +
+
+
+ ); + }) + )} +
+ )} + + {/* Trace View */} + {viewMode === 'trace' && trace && ( + + + Recall Trace + + + {/* Retrieval Methods */} + {trace.retrieval_results && (
- -
- {(['world', 'experience', 'opinion'] as FactType[]).map((ft) => ( -
- { - const newFactTypes = checked - ? [...pane.factTypes, ft] - : pane.factTypes.filter((t) => t !== ft); - updatePane(pane.id, { factTypes: newFactTypes }); - }} - /> - +

Retrieval Methods

+
+ {trace.retrieval_results.map((method: any, idx: number) => ( +
+
+ {method.method_name} + + {method.duration_seconds?.toFixed(3)}s + +
+
{method.results?.length || 0}
+
results
))}
+ )} + {/* RRF Merge */} + {trace.rrf_merged && (
- - -
- -
- - - updatePane(pane.id, { maxTokens: parseInt(e.target.value) }) - } - className="w-full" - /> -
- -
- - - updatePane(pane.id, { queryDate: e.target.value }) - } - className="w-full" - placeholder="Optional" - /> -

When is the query being asked

-
- -
- -
-
- - updatePane(pane.id, { includeChunks: checked as boolean }) - } - /> - -
-
- - updatePane(pane.id, { includeEntities: checked as boolean }) - } - /> - -
+

RRF Merge

+
+
{trace.rrf_merged.length}
+
candidates after fusion
-
-
-
+ )} - {/* Status Bar */} - {pane.trace?.summary && ( -
- βœ“ Search complete - | - - Nodes visited: {pane.trace.summary.total_nodes_visited} - - | - - Entry points: {pane.trace.summary.entry_points_found} - - | - - Results: {pane.trace.summary.results_returned} - - | - - Duration: {pane.trace.summary.total_duration_seconds?.toFixed(2)} - s - -
- )} - - {!pane.trace?.summary && !pane.loading && ( -
- Ready to search -
- )} - - {/* Phase Controls */} - {pane.trace && ( -
- updatePane(pane.id, { currentPhase: value as Phase })} - className="flex gap-3" - > -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- )} - - {/* Content */} -
- {pane.loading && ( -
+ {/* Reranking */} + {trace.reranked && (
-
πŸ”„
-
Recalling...
-
-
- )} - - {!pane.loading && !pane.trace && ( -
-
-
πŸ”
-
Enter a query and click Search
-
-
- )} - - {!pane.loading && pane.trace && ( - <> - {/* Retrieval Phase */} - {pane.currentPhase === 'retrieval' && ( -
- {/* Fact Type Tabs (only show if multiple fact types) */} - {pane.factTypes.length > 1 && ( -
- {pane.factTypes.map((ft) => ( - - ))} -
- )} - {/* Retrieval Method Tabs */} -
- {['semantic', 'bm25', 'graph', 'temporal'].map((method) => ( - - ))} -
- {renderRetrievalResults(pane)} +

Reranking

+
+
{trace.reranked.length}
+
results after cross-encoder
- )} +
+ )} + + + )} - {/* RRF Merge Phase */} - {pane.currentPhase === 'rrf' && renderRRFMerge(pane)} - - {/* Reranking Phase */} - {pane.currentPhase === 'rerank' && renderReranking(pane)} - - {/* Final Results Phase */} - {pane.currentPhase === 'final' && renderFinalResults(pane)} - - {/* Raw JSON Phase */} - {pane.currentPhase === 'json' && ( -
-

Raw JSON Response

-

- Results from the API (trace data excluded) -

-
- -
-
- )} - - )} -
-
- ))} + {/* JSON View */} + {viewMode === 'json' && ( + + + Raw Response + + +
+ +
+
+
+ )}
-
+ )} - {/* Memory Detail Panel - Fixed on Right */} + {/* Empty State */} + {!loading && !results && ( + + + +

Ready to Recall

+

+ Enter a query above to search through your memories. Use filters to narrow down by fact type, budget, and more. +

+
+
+ )} + + {/* Memory Detail Panel */} {selectedMemory && ( -
+
setSelectedMemory(null)} diff --git a/hindsight-control-plane/src/components/sidebar.tsx b/hindsight-control-plane/src/components/sidebar.tsx index 937dfeb1..29fbe105 100644 --- a/hindsight-control-plane/src/components/sidebar.tsx +++ b/hindsight-control-plane/src/components/sidebar.tsx @@ -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' )}> -
- {!isCollapsed && ( -

Hindsight

- )} - -
- -