From fa554b8980fb1dd8c88dc18aedf900307233079f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 11 Dec 2025 12:46:48 +0100 Subject: [PATCH] brandind and misc fixes --- AGENTS.md | 4 + README.md | 30 +- docker/standalone/start-all.sh | 9 +- hindsight-api/hindsight_api/api/http.py | 31 +- hindsight-api/hindsight_api/banner.py | 89 + hindsight-api/hindsight_api/config.py | 4 +- .../hindsight_api/engine/llm_wrapper.py | 41 +- .../hindsight_api/engine/memory_engine.py | 16 +- .../hindsight_api/engine/retain/link_utils.py | 52 +- hindsight-api/hindsight_api/main.py | 26 +- hindsight-api/hindsight_api/pg0.py | 15 +- hindsight-api/tests/test_llm_provider.py | 131 ++ hindsight-api/tests/test_retain.py | 132 +- hindsight-cli/Cargo.toml | 3 + hindsight-cli/src/api.rs | 19 +- hindsight-cli/src/commands/bank.rs | 149 +- hindsight-cli/src/commands/document.rs | 14 +- hindsight-cli/src/commands/entity.rs | 23 +- hindsight-cli/src/commands/explore.rs | 269 ++- hindsight-cli/src/commands/memory.rs | 28 +- hindsight-cli/src/commands/operation.rs | 8 +- hindsight-cli/src/logo.ansi | 5 + hindsight-cli/src/main.rs | 24 +- hindsight-cli/src/ui.rs | 270 ++- hindsight-control-plane/package-lock.json | 190 +-- hindsight-control-plane/package.json | 5 +- hindsight-control-plane/public/favicon.png | Bin 0 -> 27038 bytes hindsight-control-plane/public/logo.png | Bin 0 -> 46619 bytes hindsight-control-plane/src/app/globals.css | 82 +- hindsight-control-plane/src/app/layout.tsx | 14 +- .../src/components/bank-selector.tsx | 115 +- .../src/components/data-view.tsx | 639 ++++---- .../src/components/graph-2d.tsx | 599 +++++++ .../src/components/memory-detail-panel.tsx | 7 +- .../src/components/search-debug-view.tsx | 1097 ++++--------- .../src/components/sidebar.tsx | 46 +- .../src/components/ui/button.tsx | 2 +- .../src/components/ui/slider.tsx | 28 + .../src/components/ui/switch.tsx | 29 + .../src/lib/theme-context.tsx | 46 + .../benchmarks/common/benchmark_runner.py | 2 +- .../longmemeval/longmemeval_benchmark.py | 2 +- .../docs/developer/api/quickstart.md | 6 +- hindsight-docs/docs/developer/installation.md | 11 +- hindsight-docs/docs/developer/reflect.md | 21 +- hindsight-docs/docs/developer/retain.md | 16 +- hindsight-docs/docs/developer/retrieval.md | 31 +- hindsight-docs/docs/sdks/cli.md | 24 +- hindsight-docs/docs/sdks/langgraph.md | 138 -- hindsight-docs/docs/sdks/mcp.md | 89 - hindsight-docs/docs/sdks/openai.md | 112 -- hindsight-docs/docusaurus.config.ts | 58 +- hindsight-docs/openapi.json | 67 +- hindsight-docs/sidebars.ts | 22 - hindsight-docs/src/css/custom.css | 422 ++++- .../static/get-cli | 2 +- hindsight-docs/static/img/favicon.png | Bin 0 -> 27038 bytes hindsight-docs/static/img/logo.png | Bin 0 -> 46619 bytes hindsight-docs/static/img/logo.svg | 17 - hindsight-docs/static/llms-full.txt | 1448 ++++------------- hindsight-docs/static/openapi.json | 67 +- hindsight-favicon.png | Bin 0 -> 27038 bytes openapi.json | 67 +- scripts/dev/start-api.sh | 57 +- uv.lock | 8 +- 65 files changed, 3732 insertions(+), 3246 deletions(-) create mode 100644 hindsight-api/hindsight_api/banner.py create mode 100644 hindsight-api/tests/test_llm_provider.py create mode 100644 hindsight-cli/src/logo.ansi create mode 100644 hindsight-control-plane/public/favicon.png create mode 100644 hindsight-control-plane/public/logo.png create mode 100644 hindsight-control-plane/src/components/graph-2d.tsx create mode 100644 hindsight-control-plane/src/components/ui/slider.tsx create mode 100644 hindsight-control-plane/src/components/ui/switch.tsx create mode 100644 hindsight-control-plane/src/lib/theme-context.tsx delete mode 100644 hindsight-docs/docs/sdks/langgraph.md delete mode 100644 hindsight-docs/docs/sdks/mcp.md delete mode 100644 hindsight-docs/docs/sdks/openai.md rename hindsight-cli/install.sh => hindsight-docs/static/get-cli (98%) create mode 100644 hindsight-docs/static/img/favicon.png create mode 100644 hindsight-docs/static/img/logo.png delete mode 100644 hindsight-docs/static/img/logo.svg create mode 100644 hindsight-favicon.png 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 0000000000000000000000000000000000000000..8414297f1c58ba65d70492349f6d8216afca212c GIT binary patch literal 27038 zcmV)nK%KvdP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 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 N002ovPDHLkV1gVia998U literal 0 HcmV?d00001 diff --git a/hindsight-control-plane/public/logo.png b/hindsight-control-plane/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0dac3a44741a2f1304e1a039b14ad1200d76c188 GIT binary patch literal 46619 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 I!Ulo=4<`hOBme*a literal 0 HcmV?d00001 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

- )} - -
- -