Compare commits

..
Author SHA1 Message Date
Derek Bouius 06d11cf867 change the example openai model 2025-12-11 16:19:53 -05:00
Derek Bouius 7f5576cdee add best-match for the test-python-client too 2025-12-11 15:58:34 -05:00
Derek Bouius 3572387051 add best match strategy to uv 2025-12-11 15:51:16 -05:00
Derek Bouius 3a91c0b87f rework to use only CPU version of pytorch 2025-12-11 15:43:32 -05:00
Derek Bouius 706204bc4f force the CPU only version of Pytorch for tests 2025-12-11 14:58:43 -05:00
Derek Bouius 147d46fc91 Revert "use the CPU only version of pytorch to avoid pulling cuda libraries"
This reverts commit 583683b0a2.
2025-12-11 14:44:59 -05:00
Derek Bouius 285bed65f9 Revert "just use uv sync, not pip"
This reverts commit c85a1ca58b.
2025-12-11 14:44:51 -05:00
Derek Bouius c85a1ca58b just use uv sync, not pip 2025-12-11 14:21:45 -05:00
Derek Bouius 583683b0a2 use the CPU only version of pytorch to avoid pulling cuda libraries 2025-12-11 14:12:43 -05:00
Derek Bouius 0d5503c892 add debug to figure out why docker build fails sometimes 2025-12-11 13:51:06 -05:00
Derek Bouius af2756f2da remove assert that is a race condition
The test was checking that the bank count increased, but with parallel tests (-n 8), other tests can delete their banks while this test is running, causing a race condition. The important assertion is assert test_bank_id in final_banks - which verifies the bank was actually created.
2025-12-11 13:19:09 -05:00
Derek Bouius b1e380bdae Merge branch 'update-example-env' of https://github.com/vectorize-io/hindsight into update-example-env 2025-12-11 13:08:42 -05:00
Derek Bouius b8ec743962 pre-cache the model so CI doesn't need workarounds 2025-12-11 13:08:39 -05:00
Derek Bouius d891124835 Revert "trial to fix failing client tests"
This reverts commit c0093f1a97.
2025-12-11 16:08:22 +01:00
Derek Bouius 04ff24be8d trial to fix failing client tests
NotImplementedError: Cannot copy out of meta tensor; no data! Please use torch.nn.Module.to_empty() instead of torch.nn.Module.to() when moving module from meta to a different device.

Added backend_kwargs={"low_cpu_mem_usage": False} to the SentenceTransformer initialization in embeddings.py
This should ensure the low_cpu_mem_usage=False setting is passed through both model_kwargs and backend_kwargs, which are different code paths in sentence-transformers 3.x.
2025-12-11 16:08:22 +01:00
Derek Bouius 076c33e854 fix the assert in testing recall 2025-12-11 16:08:22 +01:00
Derek Bouius a03c942296 Add the LLM_PROVIDER in example 2025-12-11 16:08:22 +01:00
Derek Bouius b4e42bd0c6 lock the sentence transformer packages to align with the breaking changes around lazy tensor loading 2025-12-11 09:46:28 -05:00
Derek Bouius 95b2b7e78f Merge branch 'main' into update-example-env 2025-12-11 09:22:35 -05:00
Nicolò Boschi 2c1be4cf47 Update Docker run command in README o3 mini 2025-12-11 14:53:52 +01:00
Nicolò Boschi f148d3e338 Release v0.1.4
- Update version to 0.1.4 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-11 14:24:06 +01:00
Nicolò Boschi 99db7b26c3 fix docs on clients 2025-12-11 14:23:56 +01:00
Nicolò Boschi ebc85a5c3d fix docs build 2025-12-11 12:54:36 +01:00
Nicolò Boschi ae30882ec9 Release v0.1.3
- Update version to 0.1.3 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-11 12:48:06 +01:00
Nicolò Boschi fa554b8980 brandind and misc fixes 2025-12-11 12:46:48 +01:00
Chris Latimer f813a807e7 README banner 2025-12-10 23:59:53 -05:00
Derek Bouius 2aa8700db8 Revert "trial to fix failing client tests"
This reverts commit c0093f1a97.
2025-12-10 23:11:04 -05:00
Derek Bouius c0093f1a97 trial to fix failing client tests
NotImplementedError: Cannot copy out of meta tensor; no data! Please use torch.nn.Module.to_empty() instead of torch.nn.Module.to() when moving module from meta to a different device.

Added backend_kwargs={"low_cpu_mem_usage": False} to the SentenceTransformer initialization in embeddings.py
This should ensure the low_cpu_mem_usage=False setting is passed through both model_kwargs and backend_kwargs, which are different code paths in sentence-transformers 3.x.
2025-12-10 23:04:42 -05:00
Derek Bouius 460f045f16 fix the assert in testing recall 2025-12-10 22:36:40 -05:00
Derek Bouius 0673d4813d Add the LLM_PROVIDER in example 2025-12-10 22:22:26 -05:00
Chris Latimer f7e8b1097b Fix README images 2025-12-10 10:59:59 -07:00
Nicolò Boschi 522a491fc1 Release v0.1.2
- Update version to 0.1.2 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-10 17:56:57 +01:00
Nicolò Boschi 1056a20e71 fix docker image 2025-12-10 17:56:51 +01:00
Nicolò Boschi 01ba9744e5 Release v0.1.1
- Update version to 0.1.1 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-10 17:30:24 +01:00
Nicolò Boschi 44e79feb3e helm chart updates v1 2025-12-10 17:30:15 +01:00
Nicolò Boschi 94665b2111 improve docs 2025-12-10 16:47:41 +01:00
Nicolò Boschi f42476bf94 fix: make sure openai provider works + docs updates (#23)
* fix: make sure openai provider works

* fix: make sure openai provider works

* fix
2025-12-10 16:10:10 +01:00
113 changed files with 5110 additions and 5596 deletions
+2 -1
View File
@@ -2,8 +2,9 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
HINDSIGHT_API_LLM_MODEL=o3-mini
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# API Configuration (Optional)
+9
View File
@@ -219,6 +219,9 @@ jobs:
release-helm-chart:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
@@ -228,12 +231,18 @@ jobs:
with:
version: 'latest'
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Lint Helm chart
run: helm lint helm/hindsight
- name: Package Helm chart
run: helm package helm/hindsight --destination ./helm-packages
- name: Push to GHCR OCI
run: helm push helm-packages/*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+33 -5
View File
@@ -108,6 +108,8 @@ jobs:
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -132,7 +134,27 @@ jobs:
- name: Install dependencies
working-directory: ./hindsight-api
run: uv sync --extra test
run: uv sync --extra test --no-install-project --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Run tests
working-directory: ./hindsight-api
@@ -146,6 +168,8 @@ jobs:
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -174,11 +198,11 @@ jobs:
- name: Install client test dependencies
working-directory: ./hindsight-clients/python
run: uv sync --extra test
run: uv sync --extra test --index-strategy unsafe-best-match
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Create .env file
run: |
@@ -223,6 +247,8 @@ jobs:
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -252,7 +278,7 @@ jobs:
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Install TypeScript client dependencies
working-directory: ./hindsight-clients/typescript
@@ -305,6 +331,8 @@ jobs:
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
@@ -341,7 +369,7 @@ jobs:
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Create .env file
run: |
+4
View File
@@ -145,3 +145,7 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved
- PostgreSQL with pgvector extension
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
# Branding
## Colors
- Primary: gradient from #0074d9 to #009296
+36 -51
View File
@@ -1,16 +1,16 @@
<div align="center">
# Hindsight
![Hindsight Banner](./hindsight-docs/static/img/banner.webp)
**Agent Memory that Works Like Human Memory**
[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)
[Documentation](https://vectorize-io.github.io/hindsight) • [Paper](./Hindsight.pdf) • [Examples](./examples)
</div>
@@ -28,7 +28,7 @@ Hindsight addresses common challenges that have frustrated AI engineers building
## How Hindsight Works
![Overview](./hindsight-docs/static/img/hindsight-overview.png)
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Hindsight organizes memory into four networks to mimic the way human memory works:
@@ -54,12 +54,11 @@ 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 \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
ghcr.io/vectorize-io/hindsight:latest
```
API: http://localhost:8888
@@ -68,7 +67,7 @@ UI: http://localhost:9999
Install client:
```bash
pip install hindsight-client
pip install hindsight-client -U
# or
npm install @vectorize-io/hindsight-client
```
@@ -76,25 +75,24 @@ npm install @vectorize-io/hindsight-client
Python example:
```python
from hindsight import HindsightClient
from hindsight_client import Hindsight
client = HindsightClient(base_url="http://localhost:8888")
client = Hindsight(base_url="http://localhost:8888")
# Store
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
# Retain: Store information
client.retain(bank_id="my-bank", content="Alice works at Google as a software engineer")
# Query
results = client.recall(bank_id="my-agent", query="What does Alice do?")
# Recall: Search memories
client.recall(bank_id="my-bank", query="What does Alice do?")
# Reflect
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
print(response.text)
# Reflect: Generate disposition-aware response
client.reflect(bank_id="my-bank", query="Tell me about Alice")
```
### Python (embedded, no Docker)
```bash
pip install hindsight-all
pip install hindsight-all -U
```
```python
@@ -103,27 +101,27 @@ from hindsight import HindsightServer, HindsightClient
with HindsightServer(
llm_provider="openai",
llm_model="gpt-4o-mini",
llm_model="gpt-5-mini",
llm_api_key=os.environ["OPENAI_API_KEY"]
) as server:
client = HindsightClient(base_url=server.url)
client.retain(bank_id="my-agent", content="Alice works at Google")
results = client.recall(bank_id="my-agent", query="Where does Alice work?")
client.retain(bank_id="my-bank", content="Alice works at Google")
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```
### TypeScript
### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-agent', 'Alice loves hiking in Yosemite');
const response = await client.recall('my-agent', 'What does Alice like?');
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
await client.recall('my-bank', 'What does Alice like?');
```
---
@@ -156,7 +154,7 @@ client.retain(
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.
![Retain Operation](hindsight-docs/static/img/retain-operation.png)
![Retain Operation](hindsight-docs/static/img/retain-operation.webp)
### Recall
@@ -171,9 +169,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.recall(bank_id="my-bank", query="What does Alice do?")
# Temporal
results = client.recall(bank_id="my-bank", query="What happened in June?")
client.recall(bank_id="my-bank", query="What happened in June?")
```
Recall performs 4 retrieval strategies in parallel:
@@ -182,7 +178,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Retain Operation](hindsight-docs/static/img/recall-operation.png)
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -208,31 +204,20 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Retain Operation](hindsight-docs/static/img/reflect-operation.png)
## 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)
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
---
## 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)
@@ -250,4 +235,4 @@ MIT — see [LICENSE](./LICENSE)
---
Built by [Vectorize.io](https://vectorize.io)
Built by [Vectorize.io](https://vectorize.io)
+15 -2
View File
@@ -43,6 +43,9 @@ RUN uv sync
# Copy source code (alembic migrations are inside hindsight_api/)
COPY hindsight-api/hindsight_api ./hindsight_api
# Install the local package (uv sync only installed dependencies, not the package itself)
RUN uv pip install -e .
# =============================================================================
# Stage: SDK Builder (needed for Control Plane)
# =============================================================================
@@ -144,9 +147,13 @@ RUN mkdir -p /home/hindsight/.hindsight/bin && \
curl -fsSL -o /home/hindsight/.hindsight/bin/pg0 \
"https://github.com/vectorize-io/pg0/releases/latest/download/$PG0_BINARY" && \
chmod +x /home/hindsight/.hindsight/bin/pg0 && \
ls -lh /home/hindsight/.hindsight/bin/pg0 && \
file /home/hindsight/.hindsight/bin/pg0 && \
ldd /home/hindsight/.hindsight/bin/pg0 2>&1 || true && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
/home/hindsight/.hindsight/bin/pg0 --version
echo "Testing pg0 binary..." && \
/home/hindsight/.hindsight/bin/pg0 --version || (echo "pg0 --version failed with exit code $?"; ldd /home/hindsight/.hindsight/bin/pg0; exit 1)
# Pre-download PostgreSQL binaries
ENV PG0_HOME=/home/hindsight/.pg0-cache
@@ -174,6 +181,7 @@ ENV HINDSIGHT_API_PORT=8888
ENV HINDSIGHT_API_LOG_LEVEL=info
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=false
ENV PYTHONUNBUFFERED=1
CMD ["/app/start-all.sh"]
@@ -276,9 +284,13 @@ RUN mkdir -p /home/hindsight/.hindsight/bin && \
curl -fsSL -o /home/hindsight/.hindsight/bin/pg0 \
"https://github.com/vectorize-io/pg0/releases/latest/download/$PG0_BINARY" && \
chmod +x /home/hindsight/.hindsight/bin/pg0 && \
ls -lh /home/hindsight/.hindsight/bin/pg0 && \
file /home/hindsight/.hindsight/bin/pg0 && \
ldd /home/hindsight/.hindsight/bin/pg0 2>&1 || true && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
/home/hindsight/.hindsight/bin/pg0 --version
echo "Testing pg0 binary..." && \
/home/hindsight/.hindsight/bin/pg0 --version || (echo "pg0 --version failed with exit code $?"; ldd /home/hindsight/.hindsight/bin/pg0; exit 1)
# Pre-download PostgreSQL binaries
ENV PG0_HOME=/home/hindsight/.pg0-cache
@@ -308,6 +320,7 @@ ENV NODE_ENV=production
ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=true
ENV PYTHONUNBUFFERED=1
CMD ["/app/start-all.sh"]
+2 -7
View File
@@ -1,9 +1,6 @@
#!/bin/bash
set -e
echo "🚀 Starting Hindsight..."
echo ""
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
@@ -31,16 +28,14 @@ if [ "$ENABLE_API" = "true" ]; then
PIDS+=($API_PID)
# Wait for API to be ready
echo "⏳ Waiting for API..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null; then
echo "✅ API is ready"
break
fi
sleep 1
done
else
echo "⏭️ API disabled (HINDSIGHT_ENABLE_API=false)"
echo "API disabled (HINDSIGHT_ENABLE_API=false)"
fi
# Start Control Plane if enabled
@@ -51,7 +46,7 @@ if [ "$ENABLE_CP" = "true" ]; then
CP_PID=$!
PIDS+=($CP_PID)
else
echo "⏭️ Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
echo "Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
fi
# Print status
-135
View File
@@ -1,135 +0,0 @@
HINDSIGHT HELM CHART INSTALLATION GUIDE
=====================================
PREREQUISITES
-------------
- Kubernetes cluster (1.19+)
- kubectl configured
- Helm 3.x installed
- PostgreSQL database with pgvector extension (if not using bundled PostgreSQL)
BASIC INSTALLATION
------------------
1. Install with default values (requires external PostgreSQL):
helm install hindsight ./hindsight \
--set postgresql.external.host=your-postgres-host \
--set postgresql.external.password=your-password \
--set api.secrets.MEMORY_LLM_API_KEY=your-api-key
2. Install with custom values file:
helm install hindsight ./hindsight -f hindsight/values-production.yaml
3. Install in a specific namespace:
kubectl create namespace hindsight
helm install hindsight ./hindsight -n hindsight
CONFIGURATION OPTIONS
---------------------
Development setup (using values-development.yaml):
helm install hindsight ./hindsight -f hindsight/values-development.yaml
Production setup (using values-production.yaml):
helm install hindsight ./hindsight -f hindsight/values-production.yaml
Custom LLM provider:
helm install hindsight ./hindsight \
--set api.env.MEMORY_LLM_PROVIDER=openai \
--set api.env.MEMORY_LLM_MODEL=gpt-4 \
--set api.secrets.MEMORY_LLM_API_KEY=sk-your-key
Enable ingress:
helm install hindsight ./hindsight \
--set ingress.enabled=true \
--set ingress.hosts[0].host=hindsight.example.com
Enable autoscaling:
helm install hindsight ./hindsight \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=2 \
--set autoscaling.maxReplicas=10
UPGRADE
-------
Upgrade existing installation:
helm upgrade hindsight ./hindsight
Upgrade with new values:
helm upgrade hindsight ./hindsight -f hindsight/values-production.yaml
UNINSTALL
---------
Remove the Helm release:
helm uninstall hindsight
Remove with namespace:
helm uninstall hindsight -n hindsight
TESTING
-------
Test the installation with dry-run:
helm install hindsight ./hindsight --dry-run --debug
Validate templates:
helm template hindsight ./hindsight
Lint the chart:
helm lint ./hindsight
ACCESSING THE SERVICES
----------------------
Port-forward control plane:
kubectl port-forward svc/hindsight-control-plane 3000:3000
Port-forward API:
kubectl port-forward svc/hindsight-api 8888:8888
Get service URLs:
helm status hindsight
DATABASE INITIALIZATION
-----------------------
NOTE: Database migrations now run automatically when the API service starts.
You typically don't need to run migrations manually.
If you want to pre-initialize the database before deploying (optional):
kubectl run hindsight-init --rm -it --restart=Never \
--image=hindsight/api:latest \
--env="DATABASE_URL=postgresql://user:pass@host:5432/hindsight" \
-- python -c "from hindsight.migrations import run_migrations; run_migrations()"
TROUBLESHOOTING
---------------
Check pod status:
kubectl get pods -l app.kubernetes.io/name=hindsight
View logs for API:
kubectl logs -l app.kubernetes.io/component=api
View logs for control plane:
kubectl logs -l app.kubernetes.io/component=control-plane
Describe a pod:
kubectl describe pod <pod-name>
Check configuration:
kubectl get configmap hindsight-config -o yaml
kubectl get secret hindsight-secret -o yaml
NOTES
-----
- Make sure PostgreSQL has pgvector extension enabled
- Run database migrations before first use
- Configure proper resource limits for production
- Use external secrets management for production
- Enable TLS/SSL for production deployments
+6
View File
@@ -0,0 +1,6 @@
dependencies:
- name: postgresql
repository: https://charts.bitnami.com/bitnami
version: 15.5.38
digest: sha256:f67c7612736803ece8a669f8ca6b0555f3b78557bc0ecb732aa2e43f0df7750d
generated: "2025-12-10T17:20:57.058794+01:00"
+3 -3
View File
@@ -1,9 +1,9 @@
apiVersion: v2
name: hindsight
description: A Helm chart for Hindsight - temporal-semantic-entity memory system for AI agents
description: Hindsight helm chart
type: application
version: 0.1.0
appVersion: "0.1.0"
version: 0.1.4
appVersion: "0.1.4"
keywords:
- ai
- memory
+182
View File
@@ -0,0 +1,182 @@
# Hindsight Helm Chart
Helm chart for deploying Hindsight - a temporal-semantic-entity memory system for AI agents.
## Prerequisites
- Kubernetes 1.19+
- Helm 3.0+
- PostgreSQL database (external or bundled)
## Quick Start
```bash
# Update dependencies first
helm dependency update ./helm/hindsight
# Install (PostgreSQL included by default)
export OPENAI_API_KEY="sk-your-openai-key"
helm upgrade hindsight --install ./helm/hindsight -n hindsight --create-namespace \
--set api.secrets.HINDSIGHT_API_LLM_API_KEY="$OPENAI_API_KEY"
```
To use an external database instead:
```bash
helm install hindsight ./helm/hindsight -n hindsight --create-namespace \
--set api.secrets.HINDSIGHT_API_LLM_API_KEY="sk-your-openai-key" \
--set postgresql.enabled=false \
--set postgresql.external.host=my-postgres.example.com \
--set postgresql.external.password=mypassword
```
## Installation
### Add the repository (if published)
```bash
helm repo add hindsight https://your-helm-repo.com
helm repo update
```
### Install with custom values file
Create a `values-override.yaml`:
```yaml
api:
secrets:
HINDSIGHT_API_LLM_API_KEY: "sk-your-openai-key"
postgresql:
external:
host: "my-postgres.example.com"
password: "mypassword"
```
Then install:
```bash
helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f values-override.yaml
```
## Configuration
### Key Values
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | `0.1.0` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
| `postgresql.external.host` | External PostgreSQL host | `postgresql` |
| `postgresql.external.port` | External PostgreSQL port | `5432` |
| `postgresql.external.database` | Database name | `hindsight` |
| `postgresql.external.username` | Database username | `hindsight` |
| `ingress.enabled` | Enable ingress | `false` |
| `autoscaling.enabled` | Enable HPA | `false` |
### Environment Variables
All environment variables in `api.env` and `controlPlane.env` are automatically added to the respective pods. Sensitive values should go in `api.secrets` or `controlPlane.secrets`.
```yaml
api:
env:
HINDSIGHT_API_LLM_PROVIDER: "openai"
HINDSIGHT_API_LLM_MODEL: "gpt-4"
secrets:
HINDSIGHT_API_LLM_API_KEY: "your-api-key"
HINDSIGHT_API_LLM_BASE_URL: "https://api.openai.com/v1"
controlPlane:
env:
NODE_ENV: "production"
secrets: {}
```
### External Database
To connect to an external PostgreSQL database:
```yaml
postgresql:
enabled: false
external:
host: "my-postgres.example.com"
port: 5432
database: "hindsight"
username: "hindsight"
password: "your-password"
```
### Ingress
To expose the services via ingress:
```yaml
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: hindsight.example.com
paths:
- path: /
pathType: Prefix
service: controlPlane
- path: /api
pathType: Prefix
service: api
tls:
- secretName: hindsight-tls
hosts:
- hindsight.example.com
```
## Upgrading
```bash
helm upgrade hindsight ./helm/hindsight -n hindsight
```
## Uninstalling
```bash
helm uninstall hindsight -n hindsight
```
## Components
The chart deploys:
- **API**: The main Hindsight API server for memory operations
- **Control Plane**: Web UI for managing agents and viewing memories
## Development
### Lint the chart
```bash
helm lint ./helm/hindsight
```
### Template locally
```bash
helm template hindsight ./helm/hindsight --debug
```
### Dry run installation
```bash
helm install hindsight ./helm/hindsight --dry-run --debug
```
+2 -71
View File
@@ -1,71 +1,2 @@
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To learn more about the release, try:
$ helm status {{ .Release.Name }}
$ helm get all {{ .Release.Name }}
{{- if .Values.ingress.enabled }}
The application is accessible via the following URL(s):
{{- range .Values.ingress.hosts }}
- http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}
1. Get the Control Plane URL by running these commands:
{{- if contains "NodePort" .Values.controlPlane.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hindsight.fullname" . }}-control-plane)
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "Control Plane URL: http://$NODE_IP:$NODE_PORT"
{{- else if contains "LoadBalancer" .Values.controlPlane.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hindsight.fullname" . }}-control-plane'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hindsight.fullname" . }}-control-plane --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo "Control Plane URL: http://$SERVICE_IP:{{ .Values.controlPlane.service.port }}"
{{- else if contains "ClusterIP" .Values.controlPlane.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=control-plane,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Control Plane URL: http://127.0.0.1:3000"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 3000:$CONTAINER_PORT
{{- end }}
2. Get the API URL by running these commands:
{{- if contains "NodePort" .Values.api.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hindsight.fullname" . }}-api)
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "API URL: http://$NODE_IP:$NODE_PORT"
{{- else if contains "LoadBalancer" .Values.api.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hindsight.fullname" . }}-api'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hindsight.fullname" . }}-api --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo "API URL: http://$SERVICE_IP:{{ .Values.api.service.port }}"
{{- else if contains "ClusterIP" .Values.api.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=api,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "API URL: http://127.0.0.1:8888"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8888:$CONTAINER_PORT
{{- end }}
{{- end }}
{{- if not .Values.postgresql.enabled }}
NOTE: You are using an external PostgreSQL database.
Please ensure that:
1. The database is accessible from the cluster
2. The pgvector extension is enabled
Database migrations run automatically when the API service starts.
If you want to pre-initialize the database before deploying (optional):
kubectl run --namespace {{ .Release.Namespace }} hindsight-init --rm -it --restart=Never \
--image={{ .Values.api.image.repository }}:{{ .Values.api.image.tag }} \
--env="DATABASE_URL={{ include "hindsight.databaseUrl" . }}" \
-- python -c "from hindsight.migrations import run_migrations; run_migrations()"
{{- end }}
For more information, visit: https://github.com/yourusername/hindsight
Hindsight installed. Access the control plane:
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "hindsight.fullname" . }}-control-plane 3000:3000
+1 -1
View File
@@ -98,7 +98,7 @@ Generate database URL
{{- if .Values.databaseUrl }}
{{- .Values.databaseUrl }}
{{- else if .Values.postgresql.enabled }}
{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "hindsight.fullname" .) (.Values.postgresql.primary.service.port | int) .Values.postgresql.auth.database }}
{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "hindsight.fullname" .) (.Values.postgresql.service.port | int) .Values.postgresql.auth.database }}
{{- else }}
{{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:%d/%s" .Values.postgresql.external.username .Values.postgresql.external.host (.Values.postgresql.external.port | int) .Values.postgresql.external.database }}
{{- end }}
+8 -22
View File
@@ -15,7 +15,6 @@ spec:
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
@@ -32,7 +31,7 @@ spec:
- name: api
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
@@ -48,29 +47,16 @@ spec:
name: {{ include "hindsight.fullname" . }}-secret
key: postgres-password
{{- end }}
- name: HINDSIGHT_API_LLM_PROVIDER
valueFrom:
configMapKeyRef:
name: {{ include "hindsight.fullname" . }}-config
key: llm-provider
- name: HINDSIGHT_API_LLM_MODEL
valueFrom:
configMapKeyRef:
name: {{ include "hindsight.fullname" . }}-config
key: llm-model
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "HINDSIGHT_API_LLM_API_KEY") }}
- name: HINDSIGHT_API_LLM_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" . }}-secret
key: llm-api-key
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "HINDSIGHT_API_LLM_BASE_URL") }}
- name: HINDSIGHT_API_LLM_BASE_URL
{{- range $key, $value := .Values.api.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" . }}-secret
key: llm-base-url
name: {{ include "hindsight.fullname" $ }}-secret
key: {{ $key }}
{{- end }}
livenessProbe:
{{- toYaml .Values.api.livenessProbe | nindent 10 }}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "hindsight.fullname" . }}-config
labels:
{{- include "hindsight.labels" . | nindent 4 }}
data:
# API configuration
llm-provider: {{ .Values.api.env.HINDSIGHT_API_LLM_PROVIDER | quote }}
llm-model: {{ .Values.api.env.HINDSIGHT_API_LLM_MODEL | quote }}
# Control plane configuration
node-env: {{ .Values.controlPlane.env.NODE_ENV | quote }}
hostname: {{ .Values.controlPlane.env.HINDSIGHT_CP_HOSTNAME | quote }}
control-plane-port: {{ .Values.controlPlane.env.HINDSIGHT_CP_PORT | quote }}
@@ -15,7 +15,7 @@ spec:
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -31,30 +31,26 @@ spec:
- name: control-plane
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag }}"
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.controlPlane.service.targetPort }}
protocol: TCP
env:
- name: NODE_ENV
valueFrom:
configMapKeyRef:
name: {{ include "hindsight.fullname" . }}-config
key: node-env
- name: HINDSIGHT_CP_HOSTNAME
valueFrom:
configMapKeyRef:
name: {{ include "hindsight.fullname" . }}-config
key: hostname
- name: HINDSIGHT_CP_PORT
valueFrom:
configMapKeyRef:
name: {{ include "hindsight.fullname" . }}-config
key: control-plane-port
- name: HINDSIGHT_CP_DATAPLANE_API_URL
value: {{ include "hindsight.apiUrl" . | quote }}
{{- range $key, $value := .Values.controlPlane.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- range $key, $value := .Values.controlPlane.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" $ }}-secret
key: {{ $key }}
{{- end }}
livenessProbe:
{{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }}
readinessProbe:
@@ -0,0 +1,19 @@
{{- if .Values.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-postgresql
labels:
{{- include "hindsight.labels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
spec:
type: ClusterIP
ports:
- port: {{ .Values.postgresql.service.port }}
targetPort: postgresql
protocol: TCP
name: postgresql
selector:
{{- include "hindsight.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
{{- end }}
@@ -0,0 +1,85 @@
{{- if .Values.postgresql.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "hindsight.fullname" . }}-postgresql
labels:
{{- include "hindsight.labels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
spec:
serviceName: {{ include "hindsight.fullname" . }}-postgresql
replicas: 1
selector:
matchLabels:
{{- include "hindsight.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: postgresql
template:
metadata:
labels:
{{- include "hindsight.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: postgresql
spec:
containers:
- name: postgresql
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
ports:
- name: postgresql
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
value: {{ .Values.postgresql.auth.username | quote }}
- name: POSTGRES_PASSWORD
value: {{ .Values.postgresql.auth.password | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgresql.auth.database | quote }}
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
livenessProbe:
exec:
command:
- pg_isready
- -U
- {{ .Values.postgresql.auth.username }}
- -d
- {{ .Values.postgresql.auth.database }}
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- pg_isready
- -U
- {{ .Values.postgresql.auth.username }}
- -d
- {{ .Values.postgresql.auth.database }}
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.postgresql.resources | nindent 10 }}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
{{- if .Values.postgresql.persistence.enabled }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.postgresql.persistence.storageClass }}
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgresql.persistence.size }}
{{- else }}
volumes:
- name: data
emptyDir: {}
{{- end }}
{{- end }}
+5 -7
View File
@@ -6,14 +6,12 @@ metadata:
{{- include "hindsight.labels" . | nindent 4 }}
type: Opaque
data:
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_API_KEY") }}
llm-api-key: {{ .Values.api.secrets.MEMORY_LLM_API_KEY | b64enc | quote }}
{{- range $key, $value := .Values.api.secrets }}
{{ $key }}: {{ $value | b64enc | quote }}
{{- end }}
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_BASE_URL") }}
llm-base-url: {{ .Values.api.secrets.MEMORY_LLM_BASE_URL | b64enc | quote }}
{{- range $key, $value := .Values.controlPlane.secrets }}
{{ $key }}: {{ $value | b64enc | quote }}
{{- end }}
{{- if not .Values.postgresql.enabled }}
{{- if .Values.postgresql.external.password }}
{{- if and (not .Values.postgresql.enabled) .Values.postgresql.external.password }}
postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }}
{{- end }}
{{- end }}
+41 -18
View File
@@ -1,5 +1,8 @@
# Default values for hindsight
# Chart version - use this to set a consistent image tag across all components
version: "0.1.1"
# Global settings
replicaCount: 1
@@ -8,9 +11,9 @@ api:
enabled: true
replicaCount: 1
image:
repository: hindsight/api
repository: ghcr.io/vectorize-io/hindsight-api
pullPolicy: IfNotPresent
tag: "latest"
# tag defaults to .Values.version if not specified
service:
type: ClusterIP
@@ -29,7 +32,7 @@ api:
# Liveness and readiness probes
livenessProbe:
httpGet:
path: /
path: /health
port: 8888
initialDelaySeconds: 30
periodSeconds: 10
@@ -38,7 +41,7 @@ api:
readinessProbe:
httpGet:
path: /
path: /health
port: 8888
initialDelaySeconds: 10
periodSeconds: 5
@@ -47,7 +50,7 @@ api:
# Environment variables
env:
HINDSIGHT_API_LLM_PROVIDER: "groq"
#HINDSIGHT_API_LLM_PROVIDER: "groq"
HINDSIGHT_API_LLM_MODEL: "openai/gpt-oss-120b"
# Secret environment variables
@@ -60,9 +63,9 @@ controlPlane:
enabled: true
replicaCount: 1
image:
repository: hindsight/hindsight-control-plane
repository: ghcr.io/vectorize-io/hindsight-control-plane
pullPolicy: IfNotPresent
tag: "latest"
# tag defaults to .Values.version if not specified
service:
type: ClusterIP
@@ -78,10 +81,9 @@ controlPlane:
cpu: 250m
memory: 512Mi
# Liveness and readiness probes
# Liveness and readiness probes (TCP check)
livenessProbe:
httpGet:
path: /
tcpSocket:
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
@@ -89,8 +91,7 @@ controlPlane:
failureThreshold: 3
readinessProbe:
httpGet:
path: /
tcpSocket:
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
@@ -106,21 +107,43 @@ controlPlane:
# PostgreSQL configuration
postgresql:
# Set to true to deploy PostgreSQL as part of this chart
enabled: false
enabled: true
image:
repository: ankane/pgvector
tag: latest
pullPolicy: IfNotPresent
auth:
username: "hindsight"
password: "hindsight"
database: "hindsight"
service:
port: 5432
persistence:
enabled: true
size: 8Gi
# storageClass: ""
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 250m
memory: 256Mi
# External PostgreSQL connection details
# If postgresql.enabled is false, provide external database details
# Only used if postgresql.enabled is false
external:
host: "postgresql"
port: 5432
database: "hindsight"
username: "hindsight"
# Password should be provided via secret
# password: ""
# Database URL (auto-generated from postgresql config if not provided)
# databaseUrl: "postgresql://user:pass@host:5432/database"
# Ingress configuration
ingress:
enabled: false
+31 -2
View File
@@ -672,11 +672,15 @@ class DeleteResponse(BaseModel):
"""Response model for delete operations."""
model_config = ConfigDict(json_schema_extra={
"example": {
"success": True
"success": True,
"message": "Deleted successfully",
"deleted_count": 10
}
})
success: bool
message: Optional[str] = None
deleted_count: Optional[int] = None
def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
@@ -1696,6 +1700,31 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}",
response_model=DeleteResponse,
summary="Delete memory bank",
description="Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. "
"This is a destructive operation that cannot be undone.",
operation_id="delete_bank",
tags=["Banks"]
)
async def api_delete_bank(bank_id: str):
"""Delete an entire memory bank and all its data."""
try:
result = await app.state.memory.delete_bank(bank_id)
return DeleteResponse(
success=True,
message=f"Bank '{bank_id}' and all associated data deleted successfully",
deleted_count=result.get("memory_units_deleted", 0) + result.get("entities_deleted", 0) + result.get("documents_deleted", 0)
)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
@@ -1760,7 +1789,7 @@ def _register_routes(app: FastAPI):
# Submit task to background queue
await app.state.memory._task_backend.submit_task({
'type': 'batch_put',
'type': 'batch_retain',
'operation_id': str(operation_id),
'bank_id': bank_id,
'contents': contents
+89
View File
@@ -0,0 +1,89 @@
"""
Banner display for Hindsight API startup.
Shows the logo and tagline with gradient colors.
"""
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
# Pre-generated logo (generated by test-logo.py)
LOGO = """\
\033[38;2;9;127;184m\u2584\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m\u2584\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m\u2584\033[0m\033[38;2;7;140;156m\u2584\033[0m
\033[38;2;8;125;192m\u2584\033[0m \033[38;2;3;132;191m\u2580\033[0m\033[38;2;2;133;192m\u2584\033[0m \033[38;2;3;132;180m\u2584\033[0m\033[38;2;1;137;184m\u2584\033[0m\033[38;2;3;133;174m\u2584\033[0m \033[38;2;3;142;176m\u2584\033[0m\033[38;2;4;142;169m\u2580\033[0m \033[38;2;10;144;164m\u2584\033[0m
\033[38;2;6;121;195m\u2580\033[0m\033[38;2;5;128;203m\u2580\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m\u2584\033[0m\033[38;2;2;126;196m\u2584\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m\u2584\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m\u2584\033[0m\033[38;2;1;141;196m\u2580\033[0m\033[38;2;1;135;183m\u2580\033[0m\033[38;2;1;148;198m\u2580\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m\u2584\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m\u2584\033[0m\033[38;2;3;138;173m\u2584\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m\u2584\033[0m\033[38;2;7;144;169m\u2580\033[0m\033[38;2;7;139;158m\u2580\033[0m
\033[48;2;2;128;202m\033[38;2;2;124;201m\u2584\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m\u2584\033[0m\033[38;2;2;128;196m\u2584\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m\u2584\033[0m \033[38;2;1;135;186m\u2584\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m\u2584\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m\u2584\033[0m
\033[48;2;8;118;200m\033[38;2;8;121;209m\u2584\033[0m\033[38;2;3;121;203m\u2580\033[0m \033[38;2;3;122;192m\u2580\033[0m\033[38;2;1;138;216m\u2580\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m\u2584\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m\u2584\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m\u2584\033[0m\033[38;2;1;140;196m\u2580\033[0m \033[38;2;4;134;175m\u2580\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m\u2584\033[0m """
def _interpolate_color(start: tuple, end: tuple, t: float) -> tuple:
"""Interpolate between two RGB colors."""
return (
int(start[0] + (end[0] - start[0]) * t),
int(start[1] + (end[1] - start[1]) * t),
int(start[2] + (end[2] - start[2]) * t),
)
def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIENT_END) -> str:
"""Render text with a gradient color effect."""
result = []
length = len(text)
for i, char in enumerate(text):
if char == ' ':
result.append(' ')
else:
t = i / max(length - 1, 1)
r, g, b = _interpolate_color(start, end, t)
result.append(f"\033[38;2;{r};{g};{b}m{char}")
result.append("\033[0m")
return "".join(result)
def print_banner():
"""Print the Hindsight startup banner."""
print(LOGO)
tagline = gradient_text("Hindsight: Agent Memory That Works Like Human Memory")
print(f"\n {tagline}\n")
def color(text: str, t: float = 0.0) -> str:
"""Color text using gradient position (0.0 = start, 1.0 = end)."""
r, g, b = _interpolate_color(GRADIENT_START, GRADIENT_END, t)
return f"\033[38;2;{r};{g};{b}m{text}\033[0m"
def color_start(text: str) -> str:
"""Color text with gradient start color (#0074d9)."""
return color(text, 0.0)
def color_end(text: str) -> str:
"""Color text with gradient end color (#009296)."""
return color(text, 1.0)
def color_mid(text: str) -> str:
"""Color text with gradient middle color."""
return color(text, 0.5)
def dim(text: str) -> str:
"""Dim/gray text."""
return f"\033[38;2;128;128;128m{text}\033[0m"
def print_startup_info(host: str, port: int, database_url: str, llm_provider: str,
llm_model: str, embeddings_provider: str, reranker_provider: str,
mcp_enabled: bool = False):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
if mcp_enabled:
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
print()
+2 -2
View File
@@ -32,8 +32,8 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "groq"
DEFAULT_LLM_MODEL = "openai/gpt-oss-20b"
DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
@@ -101,12 +101,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
)
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
self._model = CrossEncoder(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
self._model = CrossEncoder(self.model_name)
logger.info("Reranker: local provider initialized")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
+219 -17
View File
@@ -6,6 +6,9 @@ import time
import asyncio
from typing import Optional, Any, Dict, List
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, APIConnectionError, LengthFinishReasonError
from google import genai
from google.genai import types as genai_types
from google.genai import errors as genai_errors
import logging
# Seed applied to every Groq request for deterministic behavior.
@@ -33,9 +36,9 @@ class OutputTooLongError(Exception):
class LLMProvider:
"""
Unified LLM provider using OpenAI-compatible API.
Unified LLM provider.
Supports OpenAI, Groq, and Ollama (any OpenAI-compatible endpoint).
Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini.
"""
def __init__(
@@ -50,7 +53,7 @@ class LLMProvider:
Initialize LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama").
provider: Provider name ("openai", "groq", "ollama", "gemini").
api_key: API key.
base_url: Base URL for the API.
model: Model name.
@@ -63,7 +66,7 @@ class LLMProvider:
self.reasoning_effort = reasoning_effort
# Validate provider
valid_providers = ["openai", "groq", "ollama"]
valid_providers = ["openai", "groq", "ollama", "gemini"]
if self.provider not in valid_providers:
raise ValueError(
f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}"
@@ -80,15 +83,43 @@ class LLMProvider:
if self.provider != "ollama" and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
# Create OpenAI-compatible client for all providers
if self.provider == "ollama":
# Create client based on provider
if self.provider == "gemini":
self._gemini_client = genai.Client(api_key=self.api_key)
self._client = None
elif self.provider == "ollama":
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,
@@ -127,24 +158,50 @@ class LLMProvider:
start_time = time.time()
import json
# Handle Gemini provider separately
if self.provider == "gemini":
return await self._call_gemini(
messages, response_format, max_retries, initial_backoff,
max_backoff, skip_validation, start_time
)
call_params = {
"model": self.model,
"messages": messages,
}
# 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"])
# For GPT-4 and GPT-4.1 models, cap max_completion_tokens to 32000
is_gpt4_model = any(x in model_lower for x in ["gpt-4.1", "gpt-4-"])
if max_completion_tokens is not None:
if is_gpt4_model and max_completion_tokens > 32000:
max_completion_tokens = 32000
# For reasoning models, max_completion_tokens includes reasoning + output tokens
# Enforce minimum of 16000 to ensure enough space for both
if is_reasoning_model and max_completion_tokens < 16000:
max_completion_tokens = 16000
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
# 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
# Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3)
if is_reasoning_model and self.provider == "openai":
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
call_params["extra_body"] = {
"service_tier": "auto",
"reasoning_effort": self.reasoning_effort,
"include_reasoning": False,
}
extra_body = {"service_tier": "auto"}
# Only add reasoning parameters for reasoning models
if is_reasoning_model:
extra_body["reasoning_effort"] = self.reasoning_effort
extra_body["include_reasoning"] = False
call_params["extra_body"] = extra_body
last_exception = None
@@ -201,7 +258,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
@@ -233,6 +291,150 @@ class LLMProvider:
raise last_exception
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
async def _call_gemini(
self,
messages: List[Dict[str, str]],
response_format: Optional[Any],
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
start_time: float,
) -> Any:
"""Handle Gemini-specific API calls."""
import json
# Convert OpenAI-style messages to Gemini format
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get('role', 'user')
content = msg.get('content', '')
if role == 'system':
if system_instruction:
system_instruction += "\n\n" + content
else:
system_instruction = content
elif role == 'assistant':
gemini_contents.append(genai_types.Content(
role="model",
parts=[genai_types.Part(text=content)]
))
else:
gemini_contents.append(genai_types.Content(
role="user",
parts=[genai_types.Part(text=content)]
))
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, 'model_json_schema'):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_instruction:
system_instruction += schema_msg
else:
system_instruction = schema_msg
# Build generation config
config_kwargs = {}
if system_instruction:
config_kwargs['system_instruction'] = system_instruction
if response_format is not None:
config_kwargs['response_mime_type'] = 'application/json'
config_kwargs['response_schema'] = response_format
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._gemini_client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
)
content = response.text
# Handle empty response
if content is None:
block_reason = None
if hasattr(response, 'candidates') and response.candidates:
candidate = response.candidates[0]
if hasattr(candidate, 'finish_reason'):
block_reason = candidate.finish_reason
if attempt < max_retries:
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
if response_format is not None:
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Log slow calls
duration = time.time() - start_time
if duration > 10.0 and hasattr(response, 'usage_metadata') and response.usage_metadata:
usage = response.usage_metadata
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, "
f"time={duration:.3f}s"
)
return result
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
raise
except genai_errors.APIError as e:
# Fast fail on 4xx client errors (except 429 rate limit)
if e.code and 400 <= e.code < 500 and e.code != 429:
logger.error(f"Gemini client error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on 429 and 5xx
if e.code in (429, 500, 502, 503, 504):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
else:
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
raise
else:
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError(f"Gemini call failed after all retries")
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
@@ -330,7 +330,7 @@ class MemoryEngine:
await self._handle_reinforce_opinion(task_dict)
elif task_type == 'form_opinion':
await self._handle_form_opinion(task_dict)
elif task_type == 'batch_put':
elif task_type == 'batch_retain':
await self._handle_batch_retain(task_dict)
elif task_type == 'regenerate_observations':
await self._handle_regenerate_observations(task_dict)
@@ -453,12 +453,17 @@ class MemoryEngine:
# Query analyzer load is sync and CPU-bound
await loop.run_in_executor(None, self.query_analyzer.load)
async def verify_llm():
"""Verify LLM connection is working."""
await self._llm_config.verify_connection()
# Run pg0 and all model initializations in parallel
await asyncio.gather(
start_pg0(),
init_embeddings(),
init_cross_encoder(),
init_query_analyzer(),
verify_llm(),
)
# Run database migrations if enabled
@@ -1791,10 +1796,14 @@ class MemoryEngine:
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
# Delete the bank profile itself
await conn.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
return {
"memory_units_deleted": units_count,
"entities_deleted": entities_count,
"documents_deleted": documents_count
"documents_deleted": documents_count,
"bank_deleted": True
}
except Exception as e:
@@ -1839,10 +1848,11 @@ class MemoryEngine:
""", *query_params)
# Get links, filtering to only include links between units of the selected agent
# Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links
unit_ids = [row['id'] for row in units]
if unit_ids:
links = await conn.fetch("""
SELECT
SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid))
ml.from_unit_id,
ml.to_unit_id,
ml.link_type,
@@ -1851,7 +1861,7 @@ class MemoryEngine:
FROM memory_links ml
LEFT JOIN entities e ON ml.entity_id = e.id
WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[])
ORDER BY ml.link_type, ml.weight DESC
ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC
""", unit_ids)
else:
links = []
@@ -390,6 +390,27 @@ async def create_temporal_links_batch_per_fact(
# Filter and create links in memory (much faster than N queries)
link_gen_start = time_mod.time()
links = compute_temporal_links(new_units, all_candidates, time_window_hours)
# Also compute temporal links WITHIN the new batch (new units to each other)
if len(new_units) > 1:
# Convert new_units dict to candidate format for within-batch linking
new_unit_items = list(new_units.items())
for i, (unit_id, event_date) in enumerate(new_unit_items):
unit_event_date_norm = _normalize_datetime(event_date)
# Compare with other new units (only those after this one to avoid duplicates)
for j in range(i + 1, len(new_unit_items)):
other_id, other_event_date = new_unit_items[j]
other_event_date_norm = _normalize_datetime(other_event_date)
# Check if within time window
time_diff_hours = abs((unit_event_date_norm - other_event_date_norm).total_seconds() / 3600)
if time_diff_hours <= time_window_hours:
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
# Create bidirectional links
links.append((unit_id, other_id, 'temporal', weight, None))
links.append((other_id, unit_id, 'temporal', weight, None))
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links:
@@ -514,9 +535,38 @@ async def create_semantic_links_batch(
for idx in sorted_indices:
similar_id = existing_ids[idx]
similarity = float(similarities[idx])
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[idx])))
all_links.append((unit_id, similar_id, 'semantic', similarity, None))
# Also compute similarities WITHIN the new batch (new units to each other)
# Apply the same top_k limit per unit as we do for existing units
if len(unit_ids) > 1:
new_embeddings_matrix = np.array(embeddings)
for i, unit_id in enumerate(unit_ids):
# Compute similarities with all OTHER new units
other_indices = [j for j in range(len(unit_ids)) if j != i]
if not other_indices:
continue
other_embeddings = new_embeddings_matrix[other_indices]
similarities = np.dot(other_embeddings, new_embeddings_matrix[i])
# Find top-k above threshold (same logic as existing units)
above_threshold = np.where(similarities >= threshold)[0]
if len(above_threshold) > 0:
# Sort by similarity (descending) and take top-k
sorted_local_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k]
for local_idx in sorted_local_indices:
other_idx = other_indices[local_idx]
other_id = unit_ids[other_idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[local_idx])))
all_links.append((unit_id, other_id, 'semantic', similarity, None))
_log(log_buffer, f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s")
if all_links:
+17 -9
View File
@@ -21,6 +21,10 @@ from . import MemoryEngine
from .api import create_app
from .config import get_config, HindsightConfig
from .banner import print_banner
print()
print_banner()
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
@@ -184,15 +188,19 @@ def main():
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
print(f"\nStarting Hindsight API...")
print(f" URL: http://{args.host}:{args.port}")
print(f" Database: {config.database_url}")
print(f" LLM: {config.llm_provider} / {config.llm_model}")
print(f" Embeddings: {config.embeddings_provider}")
print(f" Reranker: {config.reranker_provider}")
if config.mcp_enabled:
print(f" MCP: enabled at /mcp")
print()
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
uvicorn.run(**uvicorn_config)
+8 -7
View File
@@ -257,16 +257,17 @@ class EmbeddedPostgres:
last_error = stderr or f"pg0 start returned exit code {returncode}"
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.info(f"Retrying in {delay:.1f}s...")
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.debug(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
# All retries exhausted - use constructed URI as fallback
uri = f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
logger.warning(f"All pg0 start attempts failed, using constructed URI: {uri}")
return uri
# All retries exhausted - fail
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. "
f"Last error: {last_error.strip() if last_error else 'unknown'}"
)
async def stop(self) -> None:
"""Stop the PostgreSQL server."""
+4 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.1.0"
version = "0.1.4"
description = "Temporal + Semantic + Entity Memory System for AI agents using PostgreSQL"
readme = "README.md"
requires-python = ">=3.11"
@@ -14,7 +14,7 @@ dependencies = [
"openai>=1.0.0",
"pydantic>=2.0.0",
"rich>=13.0.0",
"sentence-transformers>=3.0.0",
"sentence-transformers>=3.0.0,<3.3.0",
"langchain-text-splitters>=0.3.0",
"fastapi[standard]>=0.120.3",
"uvicorn>=0.38.0",
@@ -24,8 +24,8 @@ dependencies = [
"pgvector>=0.4.1",
"greenlet>=3.2.4",
"psycopg2-binary>=2.9.11",
"transformers>=4.30.0",
"torch>=2.0.0",
"transformers>=4.30.0,<4.46.0",
"torch>=2.0.0,<2.6.0",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"fastmcp>=2.0.0",
@@ -281,7 +281,8 @@ async def test_full_api_workflow(api_client, test_bank_id):
final_banks_data = response.json()["banks"]
final_banks = [a["bank_id"] for a in final_banks_data]
assert test_bank_id in final_banks
assert len(final_banks) >= len(initial_banks) + 1
# Don't assert count increases due to parallel test cleanup races
# Just verify our bank exists in the list
# ================================================================
# 10. Clean Up
+127
View File
@@ -0,0 +1,127 @@
"""
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-4.1-mini"),
("openai", "gpt-4.1-nano"),
("openai", "gpt-5-mini"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
# Groq models
("groq", "llama-3.3-70b-versatile"),
("groq", "openai/gpt-oss-120b"),
("groq", "openai/gpt-oss-20b"),
# Gemini models
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
]
def get_api_key_for_provider(provider: str) -> str | None:
"""Get API key for provider from environment variables."""
provider_key_map = {
"openai": "OPENAI_API_KEY",
"groq": "GROQ_API_KEY",
"gemini": "GEMINI_API_KEY",
}
env_var = provider_key_map.get(provider)
return os.getenv(env_var) if env_var else 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"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
]
@pytest.mark.parametrize("provider,model", LARGE_OUTPUT_MODELS)
@pytest.mark.asyncio
async def test_llm_provider_large_output(provider: str, model: str):
"""
Test LLM provider with large max_completion_tokens (65000).
Only tests models that support large outputs.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
# Test call with large max_completion_tokens
response = await llm.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=65000,
)
print(f"\n{provider}/{model} large output response: {response}")
assert response is not None, f"{provider}/{model} returned None"
+131 -1
View File
@@ -3,7 +3,7 @@ Test retain function and chunk storage.
"""
import pytest
import logging
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@@ -1595,3 +1595,133 @@ async def test_all_link_types_together(memory):
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_semantic_links_within_same_batch(memory):
"""
Test that semantic links are created between facts retained in the SAME batch.
This is a regression test - semantic links should connect similar facts
even when they are retained together in a single call.
"""
bank_id = f"test_semantic_batch_{datetime.now(timezone.utc).timestamp()}"
try:
# Retain multiple semantically similar facts in ONE batch
contents = [
{"content": "Alice is an expert in Python programming and machine learning.", "context": "team skills"},
{"content": "Bob specializes in Python development and data science.", "context": "team skills"},
{"content": "Charlie works with Python for backend API development.", "context": "team skills"},
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
)
# Flatten the list of lists
unit_ids = [uid for sublist in result for uid in sublist]
assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}"
logger.info(f"Created {len(unit_ids)} facts in single batch")
# Query semantic links between these units
async with memory._pool.acquire() as conn:
semantic_links = await conn.fetch(
"""
SELECT from_unit_id, to_unit_id, weight
FROM memory_links
WHERE from_unit_id::text = ANY($1)
AND to_unit_id::text = ANY($1)
AND link_type = 'semantic'
""",
unit_ids
)
logger.info(f"Found {len(semantic_links)} semantic links within the batch")
# All three facts mention Python - they should be linked to each other
assert len(semantic_links) > 0, (
"REGRESSION: Semantic links should be created between similar facts "
"retained in the same batch, but none were found"
)
# Log the links for debugging
for link in semantic_links:
logger.info(f" Semantic link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_temporal_links_within_same_batch(memory):
"""
Test that temporal links are created between facts retained in the SAME batch.
This is a regression test - temporal links should connect facts with nearby
event dates even when they are retained together in a single call.
"""
bank_id = f"test_temporal_batch_{datetime.now(timezone.utc).timestamp()}"
try:
# Retain multiple facts with nearby timestamps in ONE batch
base_date = datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
contents = [
{
"content": "Morning standup: Alice presented the sprint goals.",
"context": "daily meeting",
"event_date": base_date
},
{
"content": "Bob demoed the new feature after standup.",
"context": "daily meeting",
"event_date": base_date + timedelta(hours=1) # 1 hour later
},
{
"content": "Charlie reviewed the pull requests in the afternoon.",
"context": "daily meeting",
"event_date": base_date + timedelta(hours=4) # 4 hours later
},
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
)
# Flatten the list of lists
unit_ids = [uid for sublist in result for uid in sublist]
assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}"
logger.info(f"Created {len(unit_ids)} facts in single batch")
# Query temporal links between these units
async with memory._pool.acquire() as conn:
temporal_links = await conn.fetch(
"""
SELECT from_unit_id, to_unit_id, weight
FROM memory_links
WHERE from_unit_id::text = ANY($1)
AND to_unit_id::text = ANY($1)
AND link_type = 'temporal'
""",
unit_ids
)
logger.info(f"Found {len(temporal_links)} temporal links within the batch")
# All three facts are within 24 hours - they should be linked to each other
assert len(temporal_links) > 0, (
"REGRESSION: Temporal links should be created between facts with nearby dates "
"retained in the same batch, but none were found"
)
# Log the links for debugging
for link in temporal_links:
logger.info(f" Temporal link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id)
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.1.0"
version = "0.1.4"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
@@ -20,6 +20,9 @@ clap = { version = "4.5", features = ["derive", "env"] }
# Async runtime
tokio = { version = "1", features = ["full"] }
# HTTP client (for timeout configuration)
reqwest = "0.12"
# Serialization (for config and output formatting)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+16 -3
View File
@@ -13,7 +13,7 @@ use std::collections::HashMap;
// Types not defined in OpenAPI spec (TODO: add to openapi.json)
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentStats {
pub agent_id: String,
pub bank_id: String,
pub total_nodes: i32,
pub total_links: i32,
pub total_documents: i32,
@@ -38,7 +38,7 @@ pub struct Operation {
#[derive(Debug, Serialize, Deserialize)]
pub struct OperationsResponse {
pub agent_id: String,
pub bank_id: String,
pub operations: Vec<Operation>,
}
@@ -66,7 +66,13 @@ pub struct ApiClient {
impl ApiClient {
pub fn new(base_url: String) -> Result<Self> {
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
let client = AsyncClient::new(&base_url);
// Create HTTP client with 2-minute timeout
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let client = AsyncClient::new_with_client(&base_url, http_client);
Ok(ApiClient { client, runtime })
}
@@ -231,6 +237,13 @@ impl ApiClient {
Ok(response.into_inner())
})
}
pub fn delete_bank(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.delete_bank(bank_id).await?;
Ok(response.into_inner())
})
}
}
// Re-export types from the generated client for use in commands
+90 -59
View File
@@ -12,8 +12,8 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
let response = client.list_agents(verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -36,23 +36,23 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
}
}
pub fn profile(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching profile..."))
Some(ui::create_spinner("Fetching disposition..."))
} else {
None
};
let response = client.get_profile(bank_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(profile) => {
if output_format == OutputFormat::Pretty {
ui::print_profile(&profile);
ui::print_disposition(&profile);
} else {
output::print_output(&profile, output_format)?;
}
@@ -71,92 +71,69 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
let response = client.get_stats(bank_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(stats) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Statistics for bank '{}'", bank_id));
ui::print_section_header(&format!("Statistics: {}", bank_id));
println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string()));
println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string()));
println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string()));
println!();
println!(" 📊 Overview");
println!(" Total Memory Units: {}", stats.total_nodes);
println!(" Total Links: {}", stats.total_links);
println!(" Total Documents: {}", stats.total_documents);
println!();
println!(" 🧠 Memory Units by Type");
println!("{}", ui::gradient_text("─── Memory Units by Type ───"));
let mut fact_types: Vec<_> = stats.nodes_by_fact_type.iter().collect();
fact_types.sort_by_key(|(k, _)| *k);
for (fact_type, count) in fact_types {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {:<10} {}", icon, fact_type, count);
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
let t = i as f32 / fact_types.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
}
println!();
println!(" 🔗 Links by Type");
println!("{}", ui::gradient_text("─── Links by Type ───"));
let mut link_types: Vec<_> = stats.links_by_link_type.iter().collect();
link_types.sort_by_key(|(k, _)| *k);
for (link_type, count) in link_types {
let icon = match link_type.as_str() {
"temporal" => "",
"semantic" => "🔤",
"entity" => "🏷️",
_ => ""
};
println!(" {} {:<10} {}", icon, link_type, count);
for (i, (link_type, count)) in link_types.iter().enumerate() {
let t = i as f32 / link_types.len().max(1) as f32;
println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t));
}
println!();
println!(" 🔗 Links by Fact Type");
println!("{}", ui::gradient_text("─── Links by Fact Type ───"));
let mut fact_type_links: Vec<_> = stats.links_by_fact_type.iter().collect();
fact_type_links.sort_by_key(|(k, _)| *k);
for (fact_type, count) in fact_type_links {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {:<10} {}", icon, fact_type, count);
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
let t = i as f32 / fact_type_links.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
}
println!();
if !stats.links_breakdown.is_empty() {
println!(" 📈 Detailed Link Breakdown");
println!("{}", ui::gradient_text("─── Detailed Link Breakdown ───"));
let mut fact_types: Vec<_> = stats.links_breakdown.iter().collect();
fact_types.sort_by_key(|(k, _)| *k);
for (fact_type, link_types) in fact_types {
let icon = match fact_type.as_str() {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => ""
};
println!(" {} {}", icon, fact_type);
println!(" {}", fact_type);
let mut sorted_links: Vec<_> = link_types.iter().collect();
sorted_links.sort_by_key(|(k, _)| *k);
for (link_type, count) in sorted_links {
println!(" - {:<10} {}", link_type, count);
println!(" {:<10} {}", ui::dim(link_type), count);
}
}
println!();
}
if stats.pending_operations > 0 || stats.failed_operations > 0 {
println!(" ⚙️ Operations");
println!("{}", ui::gradient_text("─── Operations ───"));
if stats.pending_operations > 0 {
println!(" ⏳ Pending: {}", stats.pending_operations);
println!(" {} {}", ui::dim("pending:"), stats.pending_operations);
}
if stats.failed_operations > 0 {
println!(" ❌ Failed: {}", stats.failed_operations);
println!(" {} {}", ui::dim("failed:"), stats.failed_operations);
}
}
} else {
@@ -177,8 +154,8 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool,
let response = client.update_agent_name(bank_id, name, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -216,8 +193,8 @@ pub fn update_background(
let response = client.add_background(bank_id, content, !no_update_disposition, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -244,3 +221,57 @@ pub fn update_background(
Err(e) => Err(e)
}
}
pub fn delete(
client: &ApiClient,
bank_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete bank '{}' and ALL its data? This cannot be undone.",
bank_id
);
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting bank..."))
} else {
None
};
let response = client.delete_bank(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
if result.success {
ui::print_success(&format!("Bank '{}' deleted successfully", bank_id));
if let Some(count) = result.deleted_count {
println!(" Items deleted: {}", count);
}
} else {
ui::print_error("Failed to delete bank");
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
+7 -7
View File
@@ -20,14 +20,14 @@ pub fn list(
let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(docs_response) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Documents for agent '{}' (total: {})", agent_id, docs_response.total));
ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total));
for doc in &docs_response.items {
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown");
@@ -65,8 +65,8 @@ pub fn get(
let response = client.get_document(agent_id, document_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -102,8 +102,8 @@ pub fn delete(
let response = client.delete_document(agent_id, document_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+6 -17
View File
@@ -18,8 +18,8 @@ pub fn list(
let response = client.list_entities(bank_id, Some(limit), verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
@@ -66,8 +66,8 @@ pub fn get(
let response = client.get_entity(bank_id, entity_id, verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
@@ -84,17 +84,6 @@ pub fn get(
println!("Last seen: {}", last_seen);
}
// Show observations (always included)
if !response.observations.is_empty() {
println!("\nObservations ({}):", response.observations.len());
for obs in &response.observations {
println!(" - {}", obs.text);
if let Some(mentioned_at) = &obs.mentioned_at {
println!(" Mentioned at: {}", mentioned_at);
}
}
}
println!();
} else {
output::print_output(&response, output_format)?;
@@ -118,8 +107,8 @@ pub fn regenerate(
let response = client.regenerate_entity(bank_id, entity_id, verbose)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
if output_format == OutputFormat::Pretty {
+185 -84
View File
@@ -16,8 +16,15 @@ use ratatui::{
Frame, Terminal,
};
use std::io;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
// Brand gradient colors: #0074d9 -> #009296
const BRAND_START: Color = Color::Rgb(0, 116, 217); // #0074d9
const BRAND_END: Color = Color::Rgb(0, 146, 150); // #009296
const BRAND_MID: Color = Color::Rgb(0, 131, 183); // Midpoint
/// Main view types (like k9s contexts)
#[derive(Debug, Clone, PartialEq)]
enum View {
@@ -61,6 +68,12 @@ enum InputMode {
Query,
}
/// Query result from background thread
enum QueryResult {
Recall(Result<Vec<RecallResult>, String>),
Reflect(Result<String, String>),
}
/// Application state
struct App {
client: ApiClient,
@@ -114,6 +127,9 @@ struct App {
auto_refresh_enabled: bool,
last_refresh: Instant,
refresh_interval: Duration,
// Background query receiver
query_receiver: Option<Receiver<QueryResult>>,
}
impl App {
@@ -160,6 +176,8 @@ impl App {
auto_refresh_enabled: true,
last_refresh: Instant::now(),
refresh_interval: Duration::from_secs(5),
query_receiver: None,
};
// Select first item by default
@@ -288,58 +306,106 @@ impl App {
Ok(())
}
fn execute_query(&mut self) -> Result<()> {
fn execute_query(&mut self) {
if let View::Query(bank_id) = &self.view {
if self.query_text.is_empty() {
self.error_message = "Query cannot be empty".to_string();
return Ok(());
return;
}
self.loading = true;
self.error_message.clear();
self.input_mode = InputMode::Normal;
match self.query_mode {
QueryMode::Recall => {
let request = RecallRequest {
query: self.query_text.clone(),
types: None,
budget: Some(self.query_budget.clone()),
max_tokens: self.query_max_tokens,
trace: false,
query_timestamp: None,
include: None,
};
// Create channel for receiving results
let (tx, rx) = mpsc::channel();
self.query_receiver = Some(rx);
let response = self.client.recall(bank_id, &request, false)?;
self.query_results = response.results;
// Clone data for the thread
let client = self.client.clone();
let bank_id = bank_id.clone();
let query_mode = self.query_mode.clone();
let query_text = self.query_text.clone();
let query_budget = self.query_budget.clone();
let query_max_tokens = self.query_max_tokens;
// Spawn background thread
thread::spawn(move || {
match query_mode {
QueryMode::Recall => {
let request = RecallRequest {
query: query_text,
types: None,
budget: Some(query_budget),
max_tokens: query_max_tokens,
trace: false,
query_timestamp: None,
include: None,
};
let result = client.recall(&bank_id, &request, false)
.map(|r| r.results)
.map_err(|e| e.to_string());
let _ = tx.send(QueryResult::Recall(result));
}
QueryMode::Reflect => {
let request = ReflectRequest {
query: query_text,
budget: Some(query_budget),
context: None,
include: None,
};
let result = client.reflect(&bank_id, &request, false)
.map(|r| r.text)
.map_err(|e| e.to_string());
let _ = tx.send(QueryResult::Reflect(result));
}
}
});
}
}
fn check_query_result(&mut self) {
if let Some(receiver) = &self.query_receiver {
match receiver.try_recv() {
Ok(QueryResult::Recall(Ok(results))) => {
self.query_results = results;
if !self.query_results.is_empty() {
self.query_results_state.select(Some(0));
}
self.loading = false;
self.status_message = format!("Found {} results", self.query_results.len());
self.query_receiver = None;
}
QueryMode::Reflect => {
let request = ReflectRequest {
query: self.query_text.clone(),
budget: Some(self.query_budget.clone()),
context: None,
include: None,
};
let response = self.client.reflect(bank_id, &request, false)?;
self.query_response = response.text;
Ok(QueryResult::Recall(Err(e))) => {
self.error_message = format!("Recall failed: {}", e);
self.loading = false;
self.query_receiver = None;
}
Ok(QueryResult::Reflect(Ok(text))) => {
self.query_response = text;
self.loading = false;
self.status_message = "Reflection complete".to_string();
self.query_receiver = None;
}
Ok(QueryResult::Reflect(Err(e))) => {
self.error_message = format!("Reflect failed: {}", e);
self.loading = false;
self.query_receiver = None;
}
Err(TryRecvError::Empty) => {
// Still waiting for result
}
Err(TryRecvError::Disconnected) => {
self.error_message = "Query thread disconnected".to_string();
self.loading = false;
self.query_receiver = None;
}
}
self.input_mode = InputMode::Normal;
}
Ok(())
}
fn toggle_query_mode(&mut self) {
@@ -700,64 +766,64 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
// Build contextual shortcuts based on view and input mode
let shortcuts = match (&app.view, &app.input_mode) {
(View::Banks, InputMode::Normal) => vec![
("Enter", "Select", Color::Cyan),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "Select", BRAND_START),
("R", "Refresh", BRAND_MID),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Memories(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("n", "Next", Color::Green),
("p", "Prev", Color::Green),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("n", "Next", BRAND_MID),
("p", "Prev", BRAND_MID),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Entities(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Documents(_), InputMode::Normal) => vec![
("Enter", "View", Color::Cyan),
("/", "Query", Color::Green),
("←→", "Scroll", Color::Cyan),
("Enter", "View", BRAND_START),
("/", "Query", BRAND_MID),
("←→", "Scroll", BRAND_START),
("Del", "Delete", Color::Red),
("Esc", "Back", Color::Yellow),
("R", "Refresh", Color::Yellow),
("?", "Help", Color::Magenta),
("Esc", "Back", BRAND_END),
("R", "Refresh", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
(View::Query(_), InputMode::Normal) => {
let mut shortcuts = vec![
("/", "Query", Color::Green),
("m", "Mode", Color::Cyan),
("/", "Query", BRAND_MID),
("m", "Mode", BRAND_START),
];
if app.query_mode == QueryMode::Recall {
shortcuts.push(("←→", "Scroll", Color::Cyan));
shortcuts.push(("←→", "Scroll", BRAND_START));
}
shortcuts.extend_from_slice(&[
("b", "Budget", Color::Yellow),
("+/-", "Tokens", Color::Yellow),
("Esc", "Back", Color::Yellow),
("?", "Help", Color::Magenta),
("b", "Budget", BRAND_END),
("+/-", "Tokens", BRAND_END),
("Esc", "Back", BRAND_END),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
]);
shortcuts
},
(View::Query(_), InputMode::Query) => vec![
("Enter", "Execute", Color::Green),
("Enter", "Execute", BRAND_MID),
("Esc", "Cancel", Color::Red),
],
_ => vec![
("?", "Help", Color::Magenta),
("?", "Help", BRAND_END),
("q", "Quit", Color::Red),
],
};
@@ -789,9 +855,9 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
let context_widget = Paragraph::new(context_info)
.block(Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(BRAND_START))
.title(" Context "))
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD))
.alignment(Alignment::Left);
f.render_widget(context_widget, columns[0]);
@@ -827,7 +893,7 @@ fn render_control_bar(f: &mut Frame, app: &App, area: Rect) {
let shortcuts_widget = Paragraph::new(shortcut_lines)
.block(Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(BRAND_START))
.title(" Shortcuts "))
.alignment(Alignment::Left);
@@ -844,7 +910,7 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) {
let title = format!("Hindsight Explorer - {}{}", app.view.title(), bank_info);
let header = Paragraph::new(title)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
@@ -859,11 +925,11 @@ fn render_footer(f: &mut Frame, app: &App, area: Rect) {
Span::raw(&app.error_message),
])
} else if app.loading {
Line::from(Span::styled(" Loading...", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))
Line::from(Span::styled(" Loading...", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)))
} else if !app.status_message.is_empty() {
Line::from(vec![
Span::raw(" "),
Span::styled(&app.status_message, Style::default().fg(Color::Green)),
Span::styled(&app.status_message, Style::default().fg(BRAND_MID)),
])
} else {
Line::from("")
@@ -928,7 +994,7 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Memory Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, chunks[0]);
@@ -946,7 +1012,7 @@ fn render_memories(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "MENTIONED AT", "OCCURRED AT", "TEXT"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1002,7 +1068,7 @@ fn render_entities(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Entity Details (Esc to close)"))
.style(Style::default().fg(Color::Cyan))
.style(Style::default().fg(BRAND_START))
.wrap(Wrap { trim: false });
f.render_widget(metadata, area);
@@ -1011,7 +1077,7 @@ fn render_entities(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<40} {:<15} {:<10}", "NAME", "TYPE", "MENTIONS"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1071,7 +1137,7 @@ fn render_documents(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Document Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, chunks[0]);
@@ -1091,7 +1157,7 @@ fn render_documents(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<40} {:<20} {}", "ID", "TYPE", "CREATED"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1137,7 +1203,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
// Query input
let query_style = if app.input_mode == InputMode::Query {
Style::default().fg(Color::Yellow)
Style::default().fg(BRAND_END)
} else {
Style::default()
};
@@ -1154,6 +1220,38 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
f.render_widget(query, chunks[0]);
// Show loading indicator if loading
if app.loading {
let loading_text = match app.query_mode {
QueryMode::Recall => "Searching memories...",
QueryMode::Reflect => "Reflecting on memories...",
};
// Create animated dots based on time
let dots = ".".repeat(((std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() / 500) % 4) as usize);
let loading_lines = vec![
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{}{}", loading_text, dots), Style::default().fg(BRAND_MID).add_modifier(Modifier::BOLD)),
]),
Line::from(""),
Line::from(Span::styled(" Please wait while we process your query...", Style::default().fg(Color::DarkGray))),
];
let loading_widget = Paragraph::new(loading_lines)
.block(Block::default().borders(Borders::ALL).title(format!("{} in progress", mode_label)))
.alignment(Alignment::Left);
f.render_widget(loading_widget, chunks[1]);
return;
}
// Results or Response based on mode
match app.query_mode {
QueryMode::Recall => {
@@ -1180,7 +1278,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
let metadata = Paragraph::new(metadata_text)
.block(Block::default().borders(Borders::ALL).title("Recall Result Metadata"))
.style(Style::default().fg(Color::Cyan));
.style(Style::default().fg(BRAND_START));
f.render_widget(metadata, recall_chunks[0]);
@@ -1196,7 +1294,7 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
let mut items = vec![
// Header row
ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "OCCURRED START", "OCCURRED END", "TEXT"))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))
];
// Data rows
@@ -1248,17 +1346,17 @@ fn render_query(f: &mut Frame, app: &mut App, area: Rect) {
fn render_help(f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(vec![
Span::styled("Navigation Flow", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Navigation Flow", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" 1. Start by selecting a bank (Enter)"),
Line::from(" 2. View memories, entities, or documents for that bank"),
Line::from(" 3. Press / from any view to query (recall/reflect)"),
Line::from(""),
Line::from(vec![
Span::styled("Basic Navigation", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Basic Navigation", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" ↑/↓, j/k - Navigate up/down in lists"),
Line::from(" ←/→, h/l - Scroll text left/right in tables"),
@@ -1266,7 +1364,7 @@ fn render_help(f: &mut Frame, area: Rect) {
Line::from(" Esc - Go back / close detail view"),
Line::from(""),
Line::from(vec![
Span::styled("Query View", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("Query View", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" / - Start or edit query (from any non-bank view)"),
Line::from(" m - Toggle mode (Recall ↔ Reflect)"),
@@ -1275,7 +1373,7 @@ fn render_help(f: &mut Frame, area: Rect) {
Line::from(" Enter - Execute query"),
Line::from(""),
Line::from(vec![
Span::styled("General", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("General", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)),
]),
Line::from(" R - Refresh current view"),
Line::from(" ? - Toggle this help screen"),
@@ -1399,7 +1497,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
match key.code {
KeyCode::Enter => {
if matches!(app.view, View::Query(_)) {
app.execute_query()?;
app.execute_query();
}
}
KeyCode::Esc => {
@@ -1422,6 +1520,9 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
}
}
// Check for query results from background thread
app.check_query_result();
// Auto-refresh check
app.do_auto_refresh()?;
}
+14 -14
View File
@@ -63,8 +63,8 @@ pub fn recall(
let response = client.recall(agent_id, &request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -104,8 +104,8 @@ pub fn reflect(
let response = client.reflect(agent_id, &request, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -154,8 +154,8 @@ pub fn retain(
let response = client.retain(agent_id, &request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -274,8 +274,8 @@ pub fn retain_files(
let response = client.retain(agent_id, &request, r#async, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -312,8 +312,8 @@ pub fn delete(
let response = client.delete_memory(agent_id, unit_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -345,12 +345,12 @@ pub fn clear(
if !yes && output_format == OutputFormat::Pretty {
let message = if let Some(ft) = &fact_type {
format!(
"Are you sure you want to clear all '{}' memories for agent '{}'? This cannot be undone.",
"Are you sure you want to clear all '{}' memories for bank '{}'? This cannot be undone.",
ft, agent_id
)
} else {
format!(
"Are you sure you want to clear ALL memories for agent '{}'? This cannot be undone.",
"Are you sure you want to clear ALL memories for bank '{}'? This cannot be undone.",
agent_id
)
};
@@ -377,8 +377,8 @@ pub fn clear(
let response = client.clear_memories(agent_id, fact_type.as_deref(), verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+4 -4
View File
@@ -17,8 +17,8 @@ pub fn list(
let response = client.list_operations(agent_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
@@ -62,8 +62,8 @@ pub fn cancel(
let response = client.cancel_operation(agent_id, operation_id, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
+5
View File
@@ -0,0 +1,5 @@
▄▄ ▄▄
▄ ▀▄ ▄▄▄ ▄▀ ▄
▀▀▄▄▄▄▀▀▀▄▄▄▄▀▀
▄▄▄ ▄ ▄▄▄
▄▀ ▀▀▄▄▄▀ ▀▄
+21 -3
View File
@@ -34,6 +34,7 @@ impl From<Format> for OutputFormat {
#[command(name = "hindsight")]
#[command(about = "Hindsight CLI - Semantic memory system", long_about = None)]
#[command(version)]
#[command(before_help = get_before_help())]
#[command(after_help = get_after_help())]
struct Cli {
/// Output format (pretty, json, yaml)
@@ -60,6 +61,10 @@ fn get_after_help() -> String {
)
}
fn get_before_help() -> &'static str {
ui::get_logo()
}
#[derive(Subcommand)]
enum Commands {
/// Manage banks (list, profile, stats)
@@ -100,8 +105,8 @@ enum BankCommands {
/// List all banks
List,
/// Get bank profile (disposition + background)
Profile {
/// Get bank disposition and background
Disposition {
/// Bank ID
bank_id: String,
},
@@ -133,6 +138,16 @@ enum BankCommands {
#[arg(long)]
no_update_disposition: bool,
},
/// Delete a bank and all its data
Delete {
/// Bank ID
bank_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
@@ -378,12 +393,15 @@ fn run() -> Result<()> {
Commands::Explore => commands::explore::run(&client),
Commands::Bank(bank_cmd) => match bank_cmd {
BankCommands::List => commands::bank::list(&client, verbose, output_format),
BankCommands::Profile { bank_id } => commands::bank::profile(&client, &bank_id, verbose, output_format),
BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format),
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
BankCommands::Background { bank_id, content, no_update_disposition } => {
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
}
BankCommands::Delete { bank_id, yes } => {
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
}
},
Commands::Memory(memory_cmd) => match memory_cmd {
+189 -81
View File
@@ -4,80 +4,132 @@ use hindsight_client::types::ChunkData;
use indicatif::{ProgressBar, ProgressStyle};
use std::io::{self, Write};
/// The logo as ANSI-colored text, generated by test-logo.py
const LOGO: &str = include_str!("logo.ansi");
// Gradient colors: #0074d9 -> #009296
const GRADIENT_START: (u8, u8, u8) = (0, 116, 217); // #0074d9
const GRADIENT_END: (u8, u8, u8) = (0, 146, 150); // #009296
/// Interpolate between two RGB colors
fn interpolate_color(start: (u8, u8, u8), end: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
(
(start.0 as f32 + (end.0 as f32 - start.0 as f32) * t) as u8,
(start.1 as f32 + (end.1 as f32 - start.1 as f32) * t) as u8,
(start.2 as f32 + (end.2 as f32 - start.2 as f32) * t) as u8,
)
}
/// Color text using gradient position (0.0 = start, 1.0 = end)
pub fn gradient(text: &str, t: f32) -> String {
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text)
}
/// Color text with gradient start color (#0074d9)
pub fn gradient_start(text: &str) -> String {
gradient(text, 0.0)
}
/// Color text with gradient end color (#009296)
pub fn gradient_end(text: &str) -> String {
gradient(text, 1.0)
}
/// Color text with gradient middle color
pub fn gradient_mid(text: &str) -> String {
gradient(text, 0.5)
}
/// Apply gradient across entire text string
pub fn gradient_text(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return String::new();
}
let mut result = String::new();
for (i, ch) in chars.iter().enumerate() {
if *ch == ' ' {
result.push(' ');
} else {
let t = i as f32 / (len - 1).max(1) as f32;
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch));
}
}
result.push_str("\x1b[0m");
result
}
/// Dim/gray text
pub fn dim(text: &str) -> String {
format!("\x1b[38;2;128;128;128m{}\x1b[0m", text)
}
pub fn get_logo() -> &'static str {
LOGO
}
pub fn print_section_header(title: &str) {
println!();
println!("{}", format!("━━━ {} ━━━", title).bright_yellow().bold());
println!("{}", gradient_text(&format!("━━━ {} ━━━", title)));
println!();
}
pub fn print_fact(fact: &RecallResult, show_activation: bool) {
pub fn print_fact(fact: &RecallResult, _show_activation: bool) {
let fact_type = fact.type_.as_deref().unwrap_or("unknown");
let type_color = match fact_type {
"world" => "cyan",
"agent" => "magenta",
"opinion" => "yellow",
_ => "white",
// Use gradient positions for different fact types
let type_t = match fact_type {
"world" => 0.0,
"agent" => 0.5,
"opinion" => 1.0,
_ => 0.5,
};
let prefix = match fact_type {
"world" => "🌍",
"agent" => "🤖",
"opinion" => "💭",
_ => "📝",
};
print!("{} ", prefix);
print!("{}", format!("[{}]", fact_type.to_uppercase()).color(type_color).bold());
// Note: activation field not available in generated SearchResult
// The API doesn't return it in the current schema
if show_activation {
// Placeholder for when activation is added to the API schema
}
println!();
println!("{}", gradient(&format!("[{}]", fact_type.to_uppercase()), type_t));
println!(" {}", fact.text);
// Show context if available
if let Some(context) = &fact.context {
println!(" {}: {}", "Context".bright_black(), context.bright_black());
println!(" {} {}", dim("context:"), dim(context));
}
// Show temporal information
if let Some(occurred_start) = &fact.occurred_start {
if let Some(occurred_end) = &fact.occurred_end {
println!(" {}: {} - {}", "Date".bright_black(), occurred_start.bright_black(), occurred_end.bright_black());
println!(" {} {} - {}", dim("date:"), dim(occurred_start), dim(occurred_end));
} else {
println!(" {}: {}", "Date".bright_black(), occurred_start.bright_black());
println!(" {} {}", dim("date:"), dim(occurred_start));
}
}
// Show document ID if available
if let Some(document_id) = &fact.document_id {
println!(" {}: {}", "Document".bright_black(), document_id.bright_black());
println!(" {} {}", dim("document:"), dim(document_id));
}
println!();
}
pub fn print_chunk(chunk: &ChunkData) {
println!(" {}", "─── Source Chunk ───".bright_blue());
println!(" {}", gradient_mid("─── Source Chunk ───"));
// Split text into lines and indent each line
for line in chunk.text.lines() {
println!(" {}", line.bright_white());
println!(" {}", line);
}
if chunk.truncated {
println!(" {}", "[Truncated due to token limit]".bright_yellow());
println!(" {}", gradient_end("[Truncated due to token limit]"));
}
println!(" {}: {} | {}: {}",
"Chunk ID".bright_black(),
chunk.id.bright_black(),
"Index".bright_black(),
chunk.chunk_index.to_string().bright_black()
println!(" {} {} | {} {}",
dim("Chunk ID:"),
dim(&chunk.id),
dim("Index:"),
dim(&chunk.chunk_index.to_string())
);
println!();
@@ -88,10 +140,10 @@ pub fn print_search_results(response: &RecallResponse, show_trace: bool, show_ch
print_section_header(&format!("Search Results ({})", results.len()));
if results.is_empty() {
println!("{}", " No results found.".bright_black());
println!(" {}", dim("No results found."));
} else {
for (i, fact) in results.iter().enumerate() {
println!("{}", format!(" Result #{}", i + 1).bright_black());
println!(" {}", dim(&format!("Result #{}", i + 1)));
print_fact(fact, true);
// Show chunk if available and requested
@@ -115,56 +167,120 @@ pub fn print_search_results(response: &RecallResponse, show_trace: bool, show_ch
}
pub fn print_think_response(response: &ReflectResponse) {
println!();
println!("{}", response.text.bright_white());
print_section_header("Reflection");
println!("{}", response.text);
println!();
if !response.based_on.is_empty() {
println!("{}", format!("Based on {} memory units", response.based_on.len()).bright_black());
println!("{}", dim(&format!("Based on {} memory units", response.based_on.len())));
}
}
pub fn print_trace_info(trace: &serde_json::Map<String, serde_json::Value>) {
print_section_header("Trace Information");
print_section_header("Trace");
if let Some(time) = trace.get("total_time").and_then(|v| v.as_f64()) {
println!(" ⏱️ Total time: {}", format!("{:.2}ms", time).bright_green());
println!(" {} {}", dim("total time:"), gradient_start(&format!("{:.2}ms", time)));
}
if let Some(count) = trace.get("activation_count").and_then(|v| v.as_i64()) {
println!(" 📊 Activation count: {}", count.to_string().bright_green());
println!(" {} {}", dim("activation count:"), gradient_end(&count.to_string()));
}
println!();
}
pub fn print_success(message: &str) {
println!("{} {}", "".bright_green().bold(), message.bright_white());
println!("{}", gradient_start(message));
}
pub fn print_error(message: &str) {
eprintln!("{} {}", "".bright_red().bold(), message.bright_red());
eprintln!("{} {}", "error:".bright_red().bold(), message.bright_red());
}
pub fn print_warning(message: &str) {
println!("{} {}", "".bright_yellow().bold(), message.bright_yellow());
println!("{} {}", gradient_end("warning:"), message);
}
pub fn print_info(message: &str) {
println!("{} {}", "".bright_blue().bold(), message.bright_white());
println!("{}", gradient_start(message));
}
pub fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
/// Animated gradient spinner that shows text with moving gradient colors
pub struct GradientSpinner {
message: String,
running: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl GradientSpinner {
pub fn new(message: &str) -> Self {
let message = message.to_string();
let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let msg_clone = message.clone();
let running_clone = running.clone();
let handle = std::thread::spawn(move || {
let chars: Vec<char> = msg_clone.chars().collect();
let len = chars.len();
let num_frames = 30;
let mut current_frame = 0usize;
while running_clone.load(std::sync::atomic::Ordering::Relaxed) {
current_frame = (current_frame + 1) % num_frames;
let offset = current_frame as f32 / num_frames as f32;
// Build the gradient string
let mut result = String::from("\r");
for (i, ch) in chars.iter().enumerate() {
if *ch == ' ' {
result.push(' ');
} else {
let base_t = if len > 1 { i as f32 / (len - 1) as f32 } else { 0.0 };
let t = (base_t + offset) % 1.0;
let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t);
result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch));
}
}
result.push_str("\x1b[0m");
print!("{}", result);
let _ = io::stdout().flush();
std::thread::sleep(std::time::Duration::from_millis(80));
}
});
Self {
message,
running,
handle: Some(handle),
}
}
pub fn finish(&mut self) {
self.running.store(false, std::sync::atomic::Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
// Clear the line
print!("\r{}\r", " ".repeat(self.message.len() + 10));
let _ = io::stdout().flush();
}
}
impl Drop for GradientSpinner {
fn drop(&mut self) {
if self.running.load(std::sync::atomic::Ordering::Relaxed) {
self.finish();
}
}
}
pub fn create_spinner(message: &str) -> GradientSpinner {
GradientSpinner::new(message)
}
pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
@@ -180,7 +296,7 @@ pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
}
pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
print!("{} {} [y/N]: ", "?".bright_blue().bold(), message);
print!("{} [y/N]: ", gradient_start(message));
io::stdout().flush()?;
let mut input = String::new();
@@ -189,16 +305,16 @@ pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes"))
}
pub fn print_profile(profile: &BankProfileResponse) {
print_section_header(&format!("Bank Profile: {}", profile.bank_id));
pub fn print_disposition(profile: &BankProfileResponse) {
print_section_header(&format!("Disposition: {}", profile.bank_id));
// Print name
println!("{} {}", "Name:".bright_cyan().bold(), profile.name.bright_white());
println!("{} {}", dim("Name:"), gradient_start(&profile.name));
println!();
// Print background if available
if !profile.background.is_empty() {
println!("{}", "Background:".bright_yellow());
println!("{}", gradient_mid("Background:"));
for line in profile.background.lines() {
println!("{}", line);
}
@@ -206,38 +322,30 @@ pub fn print_profile(profile: &BankProfileResponse) {
}
// Print disposition traits
println!("{}", "─── Disposition Traits ───".bright_yellow());
println!("{}", gradient_text("─── Disposition Traits ───"));
println!();
// New 3-trait disposition system (values 1-5)
let traits: [(_, i64, _, _, _); 3] = [
("Skepticism", profile.disposition.skepticism.get() as i64, "🔍", "cyan", "1=trusting, 5=skeptical"),
("Literalism", profile.disposition.literalism.get() as i64, "📋", "yellow", "1=flexible, 5=literal"),
("Empathy", profile.disposition.empathy.get() as i64, "💚", "green", "1=detached, 5=empathetic"),
let traits: [(_, i64, f32, _); 3] = [
("Skepticism", profile.disposition.skepticism.get() as i64, 0.0, "1=trusting, 5=skeptical"),
("Literalism", profile.disposition.literalism.get() as i64, 0.5, "1=flexible, 5=literal"),
("Empathy", profile.disposition.empathy.get() as i64, 1.0, "1=detached, 5=empathetic"),
];
for (name, value, emoji, color, desc) in &traits {
for (name, value, t, desc) in &traits {
// Scale 1-5 to bar visualization (each point = 8 chars, total 40)
let bar_length = 40;
let filled = ((*value - 1) * 10) as usize; // 1->0, 2->10, 3->20, 4->30, 5->40
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let colored_bar = match *color {
"green" => bar.bright_green(),
"yellow" => bar.bright_yellow(),
"cyan" => bar.bright_cyan(),
"magenta" => bar.bright_magenta(),
_ => bar.bright_white(),
};
println!(" {} {:<12} [{}] {}/5",
emoji,
println!(" {:<12} [{}] {}/5",
name,
colored_bar,
gradient(&bar, *t),
value
);
println!(" {}", desc.bright_black());
println!(" {}", dim(desc));
}
println!();
@@ -15,8 +15,8 @@ Example:
print(result.success)
# Search memories
results = client.recall(bank_id="alice", query="What does Alice like?")
for r in results:
response = client.recall(bank_id="alice", query="What does Alice like?")
for r in response.results:
print(r.text)
# Generate contextual answer
@@ -29,14 +29,59 @@ from .hindsight_client import Hindsight
# Re-export response types for convenient access
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.recall_response import RecallResponse as _RecallResponse
from hindsight_client_api.models.recall_result import RecallResult as _RecallResult
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
# Add cleaner __repr__ and __iter__ for REPL usability
def _recall_result_repr(self):
text_preview = self.text[:80] + "..." if len(self.text) > 80 else self.text
return f"RecallResult(id='{self.id[:8]}...', type='{self.type}', text='{text_preview}')"
def _recall_response_repr(self):
count = len(self.results) if self.results else 0
extras = []
if self.trace:
extras.append("trace=True")
if self.entities:
extras.append(f"entities={len(self.entities)}")
if self.chunks:
extras.append(f"chunks={len(self.chunks)}")
extras_str = ", " + ", ".join(extras) if extras else ""
return f"RecallResponse({count} results{extras_str})"
def _recall_response_iter(self):
"""Iterate directly over results for convenience."""
return iter(self.results or [])
def _recall_response_len(self):
"""Return number of results."""
return len(self.results) if self.results else 0
def _recall_response_getitem(self, index):
"""Access results by index."""
return self.results[index]
_RecallResult.__repr__ = _recall_result_repr
_RecallResponse.__repr__ = _recall_response_repr
_RecallResponse.__iter__ = _recall_response_iter
_RecallResponse.__len__ = _recall_response_len
_RecallResponse.__getitem__ = _recall_response_getitem
# Re-export with patched repr
RecallResult = _RecallResult
RecallResponse = _RecallResponse
__all__ = [
"Hindsight",
# Response types
@@ -50,7 +50,9 @@ class Hindsight:
client.retain(bank_id="alice", content="Alice loves AI")
# Recall memories
results = client.recall(bank_id="alice", query="What does Alice like?")
response = client.recall(bank_id="alice", query="What does Alice like?")
for r in response.results:
print(r.text)
# Generate contextual answer
answer = client.reflect(bank_id="alice", query="What are my interests?")
@@ -125,8 +127,8 @@ class Hindsight:
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata'
document_id: Optional document ID for grouping memories
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
retain_async: If True, process asynchronously in background (default: False)
Returns:
@@ -138,13 +140,14 @@ class Hindsight:
timestamp=item.get("timestamp"),
context=item.get("context"),
metadata=item.get("metadata"),
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
)
for item in items
]
request_obj = retain_request.RetainRequest(
items=memory_items,
document_id=document_id,
async_=retain_async,
)
@@ -157,7 +160,13 @@ class Hindsight:
types: Optional[List[str]] = None,
max_tokens: int = 4096,
budget: str = "mid",
) -> List[RecallResult]:
trace: bool = False,
query_timestamp: Optional[str] = None,
include_entities: bool = False,
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
) -> RecallResponse:
"""
Recall memories using semantic similarity.
@@ -167,20 +176,34 @@ class Hindsight:
types: Optional list of fact types to filter (world, experience, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
trace: Enable trace output (default: False)
query_timestamp: Optional ISO format date string (e.g., '2023-05-30T23:40:00')
include_entities: Include entity observations in results (default: False)
max_entity_tokens: Maximum tokens for entity observations (default: 500)
include_chunks: Include raw text chunks in results (default: False)
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
Returns:
List of RecallResult objects
RecallResponse with results, optional entities, optional chunks, and optional trace
"""
from hindsight_client_api.models import include_options, entity_include_options, chunk_include_options
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens) if include_entities else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
)
request_obj = recall_request.RecallRequest(
query=query,
types=types,
budget=budget,
max_tokens=max_tokens,
trace=False,
trace=trace,
query_timestamp=query_timestamp,
include=include_opts,
)
response = _run_async(self._api.recall_memories(bank_id, request_obj))
return response.results if hasattr(response, 'results') else []
return _run_async(self._api.recall_memories(bank_id, request_obj))
def reflect(
self,
@@ -209,55 +232,6 @@ class Hindsight:
return _run_async(self._api.reflect(bank_id, request_obj))
# Full-featured methods (expose more options)
def recall_memories(
self,
bank_id: str,
query: str,
types: Optional[List[str]] = None,
budget: str = "mid",
max_tokens: int = 4096,
trace: bool = False,
query_timestamp: Optional[str] = None,
include_entities: bool = True,
max_entity_tokens: int = 500,
) -> RecallResponse:
"""
Recall memories with all options (full-featured).
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
budget: Budget level - "low", "mid", or "high"
max_tokens: Maximum tokens in results
trace: Enable trace output
query_timestamp: Optional ISO format date string (e.g., '2023-05-30T23:40:00')
include_entities: Include entity observations in results (default: True)
max_entity_tokens: Maximum tokens for entity observations (default: 500)
Returns:
RecallResponse with results, optional entities, and optional trace
"""
from hindsight_client_api.models import include_options, entity_include_options
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens) if include_entities else None
)
request_obj = recall_request.RecallRequest(
query=query,
types=types,
budget=budget,
max_tokens=max_tokens,
trace=trace,
query_timestamp=query_timestamp,
include=include_opts,
)
return _run_async(self._api.recall_memories(bank_id, request_obj))
def list_memories(
self,
bank_id: str,
@@ -311,8 +285,8 @@ class Hindsight:
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata'
document_id: Optional document ID for grouping memories
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
retain_async: If True, process asynchronously in background (default: False)
Returns:
@@ -324,13 +298,14 @@ class Hindsight:
timestamp=item.get("timestamp"),
context=item.get("context"),
metadata=item.get("metadata"),
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
)
for item in items
]
request_obj = retain_request.RetainRequest(
items=memory_items,
document_id=document_id,
async_=retain_async,
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hindsight-client"
version = "0.1.0"
version = "0.1.4"
description = "Python client for Hindsight - Semantic memory system with personality-driven thinking"
authors = [
{name = "Hindsight Team"}
@@ -92,32 +92,33 @@ class TestRecall:
def test_recall_basic(self, client, bank_id):
"""Test basic memory search."""
results = client.recall(
response = client.recall(
bank_id=bank_id,
query="What does Alice like?",
)
assert results is not None
assert len(results) > 0
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# Check that at least one result contains relevant information
result_texts = [r.text for r in results]
result_texts = [r.text for r in response.results]
assert any("Alice" in text or "Python" in text or "programming" in text for text in result_texts)
def test_recall_with_max_tokens(self, client, bank_id):
"""Test search with token limit."""
results = client.recall(
response = client.recall(
bank_id=bank_id,
query="outdoor activities",
max_tokens=1024,
)
assert results is not None
assert isinstance(results, list)
assert response is not None
assert response.results is not None
def test_recall_memories_full_featured(self, client, bank_id):
"""Test recall_memories with all features."""
response = client.recall_memories(
def test_recall_full_featured(self, client, bank_id):
"""Test recall with all features."""
response = client.recall(
bank_id=bank_id,
query="What are people's hobbies?",
types=["world"],
@@ -238,7 +239,7 @@ class TestEndToEndWorkflow:
bank_id=workflow_bank_id,
query="What programming technologies do I use?",
)
assert len(search_results) > 0
assert len(search_results.results) > 0
# 4. Generate contextual answer
reflect_response = client.reflect(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-client",
"version": "0.1.0",
"version": "0.1.4",
"description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
+16 -1
View File
@@ -128,7 +128,17 @@ export class HindsightClient {
async recall(
bankId: string,
query: string,
options?: { types?: string[]; maxTokens?: number; budget?: Budget; trace?: boolean }
options?: {
types?: string[];
maxTokens?: number;
budget?: Budget;
trace?: boolean;
queryTimestamp?: string;
includeEntities?: boolean;
maxEntityTokens?: number;
includeChunks?: boolean;
maxChunkTokens?: number;
}
): Promise<RecallResponse> {
const response = await sdk.recallMemories({
client: this.client,
@@ -139,6 +149,11 @@ export class HindsightClient {
max_tokens: options?.maxTokens,
budget: options?.budget || 'mid',
trace: options?.trace,
query_timestamp: options?.queryTimestamp,
include: {
entities: options?.includeEntities ? { max_tokens: options?.maxEntityTokens ?? 500 } : undefined,
chunks: options?.includeChunks ? { max_tokens: options?.maxChunkTokens ?? 8192 } : undefined,
},
},
});
+83 -107
View File
@@ -1,12 +1,12 @@
{
"name": "hindsight-control-plane",
"version": "0.0.21",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hindsight-control-plane",
"version": "0.0.21",
"version": "0.1.2",
"license": "ISC",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
@@ -15,8 +15,11 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -33,19 +36,19 @@
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-cytoscape": "^1.0.6",
"react-dom": "^19.2.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"typescript": "^5.9.3"
}
},
"../hindsight-clients/typescript": {
"name": "@vectorize-io/hindsight-client",
"version": "0.0.21",
"version": "0.1.2",
"license": "MIT",
"devDependencies": {
"@hey-api/openapi-ts": "^0.88.0",
@@ -5045,6 +5048,39 @@
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"license": "MIT",
@@ -5061,6 +5097,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"license": "MIT",
@@ -5324,6 +5389,12 @@
"tailwindcss": "4.1.17"
}
},
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -6219,31 +6290,13 @@
},
"node_modules/cytoscape": {
"version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-cola": {
"version": "2.5.1",
"license": "MIT",
"dependencies": {
"webcola": "^3.4.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-dagre": {
"version": "2.5.0",
"license": "MIT",
"dependencies": {
"dagre": "^0.8.5"
},
"peerDependencies": {
"cytoscape": "^3.2.22"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -6265,18 +6318,6 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "1.0.6",
"license": "BSD-3-Clause"
},
"node_modules/d3-drag": {
"version": "1.2.5",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1",
"d3-selection": "1"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -6307,10 +6348,6 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "1.0.9",
"license": "BSD-3-Clause"
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -6327,17 +6364,6 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "1.4.2",
"license": "BSD-3-Clause"
},
"node_modules/d3-shape": {
"version": "1.3.7",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
@@ -6362,18 +6388,6 @@
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "1.0.10",
"license": "BSD-3-Clause"
},
"node_modules/dagre": {
"version": "0.8.5",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"license": "BSD-2-Clause"
@@ -7215,17 +7229,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -7389,13 +7392,6 @@
"version": "1.4.0",
"license": "MIT"
},
"node_modules/graphlib": {
"version": "2.1.8",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"license": "MIT",
@@ -8044,10 +8040,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"license": "MIT"
@@ -8550,18 +8542,6 @@
"styled-components": "^6.0.0"
}
},
"node_modules/react-cytoscape": {
"version": "1.0.6",
"license": "MIT",
"dependencies": {
"cytoscape": "^3.2.5",
"cytoscape-cola": "^2.0.0",
"cytoscape-dagre": "^2.1.0"
},
"optionalDependencies": {
"fsevents": "*"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"license": "MIT",
@@ -9315,6 +9295,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -9713,16 +9699,6 @@
"node": ">=12"
}
},
"node_modules/webcola": {
"version": "3.4.0",
"license": "MIT",
"dependencies": {
"d3-dispatch": "^1.0.3",
"d3-drag": "^1.0.4",
"d3-shape": "^1.3.5",
"d3-timer": "^1.0.5"
}
},
"node_modules/which": {
"version": "2.0.2",
"license": "ISC",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hindsight-control-plane",
"version": "0.1.0",
"version": "0.1.4",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,8 +19,11 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/postcss": "^4.1.17",
"@types/cytoscape": "^3.21.9",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -37,13 +40,13 @@
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-chrono": "^2.9.1",
"react-cytoscape": "^1.0.6",
"react-dom": "^19.2.0",
"react18-json-view": "^0.2.9",
"recharts": "^3.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"typescript": "^5.9.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+46 -36
View File
@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap');
@import "tailwindcss";
:root {
--background: oklch(0.9911 0 0);
@@ -6,8 +7,9 @@
--card-foreground: oklch(0.2046 0 0);
--popover: oklch(0.9911 0 0);
--popover-foreground: oklch(0.4386 0 0);
--primary: oklch(0.8348 0.1302 160.9080);
--primary-foreground: oklch(0.2626 0.0147 166.4589);
--primary: oklch(0.55 0.19 250);
--primary-foreground: oklch(0.98 0.01 250);
--primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--secondary: oklch(0.9940 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9461 0 0);
@@ -18,23 +20,23 @@
--destructive-foreground: oklch(0.9934 0.0032 17.2118);
--border: oklch(0.9037 0 0);
--input: oklch(0.9731 0 0);
--ring: oklch(0.8348 0.1302 160.9080);
--chart-1: oklch(0.8348 0.1302 160.9080);
--ring: oklch(0.55 0.19 250);
--chart-1: oklch(0.55 0.19 250);
--chart-2: oklch(0.6231 0.1880 259.8145);
--chart-3: oklch(0.6056 0.2189 292.7172);
--chart-4: oklch(0.7686 0.1647 70.0804);
--chart-5: oklch(0.6959 0.1491 162.4796);
--sidebar: oklch(0.9911 0 0);
--sidebar-foreground: oklch(0.5452 0 0);
--sidebar-primary: oklch(0.8348 0.1302 160.9080);
--sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589);
--sidebar-primary: oklch(0.55 0.19 250);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.9461 0 0);
--sidebar-accent-foreground: oklch(0.2435 0 0);
--sidebar-border: oklch(0.9037 0 0);
--sidebar-ring: oklch(0.8348 0.1302 160.9080);
--font-sans: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--sidebar-ring: oklch(0.55 0.19 250);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-heading: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
@@ -61,8 +63,9 @@
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2603 0 0);
--popover-foreground: oklch(0.7348 0 0);
--primary: oklch(0.4365 0.1044 156.7556);
--primary-foreground: oklch(0.9213 0.0135 167.1556);
--primary: oklch(0.60 0.17 250);
--primary-foreground: oklch(0.98 0.01 250);
--primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--secondary: oklch(0.2603 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2393 0 0);
@@ -73,38 +76,20 @@
--destructive-foreground: oklch(0.9368 0.0045 34.3092);
--border: oklch(0.2809 0 0);
--input: oklch(0.2603 0 0);
--ring: oklch(0.8003 0.1821 151.7110);
--chart-1: oklch(0.8003 0.1821 151.7110);
--ring: oklch(0.60 0.17 250);
--chart-1: oklch(0.60 0.17 250);
--chart-2: oklch(0.7137 0.1434 254.6240);
--chart-3: oklch(0.7090 0.1592 293.5412);
--chart-4: oklch(0.8369 0.1644 84.4286);
--chart-5: oklch(0.7845 0.1325 181.9120);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.6301 0 0);
--sidebar-primary: oklch(0.4365 0.1044 156.7556);
--sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556);
--sidebar-primary: oklch(0.60 0.17 250);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.3132 0 0);
--sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(0.2809 0 0);
--sidebar-ring: oklch(0.8003 0.1821 151.7110);
--font-sans: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.17;
--shadow-color: #000000;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--sidebar-ring: oklch(0.60 0.17 250);
}
@theme inline {
@@ -143,7 +128,7 @@
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-heading: var(--font-heading);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -170,4 +155,29 @@
body {
font-family: var(--font-sans);
letter-spacing: var(--tracking-normal);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600;
}
code, pre {
font-family: var(--font-mono);
}
/* Gradient utilities */
.bg-primary-gradient {
background: var(--primary-gradient);
}
.border-primary-gradient {
border-image: var(--primary-gradient) 1;
}
.text-primary-gradient {
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
+10 -4
View File
@@ -1,10 +1,14 @@
import type { Metadata } from "next";
import "./globals.css";
import { BankProvider } from "@/lib/bank-context";
import { ThemeProvider } from "@/lib/theme-context";
export const metadata: Metadata = {
title: "Hindsight Control Plane",
description: "Control plane for the temporal semantic memory system",
icons: {
icon: "/favicon.png",
},
};
export default function RootLayout({
@@ -13,11 +17,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body>
<BankProvider>
{children}
</BankProvider>
<ThemeProvider>
<BankProvider>
{children}
</BankProvider>
</ThemeProvider>
</body>
</html>
);
@@ -27,7 +27,9 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Check, ChevronsUpDown, Plus, FileText } from 'lucide-react';
import { Check, ChevronsUpDown, Plus, FileText, Moon, Sun, Github } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import Image from 'next/image';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
@@ -36,6 +38,7 @@ function BankSelectorInner() {
const router = useRouter();
const searchParams = useSearchParams();
const { currentBank, setCurrentBank, banks, loadBanks } = useBank();
const { theme, toggleTheme } = useTheme();
const [open, setOpen] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const [newBankId, setNewBankId] = React.useState('');
@@ -119,26 +122,34 @@ function BankSelectorInner() {
};
return (
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary">
<div className="flex items-center gap-2.5 text-sm">
<span className="font-medium">Memory Bank:</span>
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
<div className="flex items-center gap-4 text-sm">
{/* Logo */}
<Image src="/logo.png" alt="Hindsight" width={40} height={40} className="h-10 w-auto" unoptimized />
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Memory Bank Selector */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[300px] justify-between font-bold border-2 border-primary hover:bg-accent"
className="w-[250px] justify-between font-bold border-2 border-primary hover:bg-accent"
>
{currentBank || "Select a memory bank..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<PopoverContent className="w-[250px] p-0">
<Command>
<CommandInput placeholder="Search memory banks..." />
{sortedBanks.length > 0 && (
<CommandInput placeholder="Search memory banks..." />
)}
<CommandList>
<CommandEmpty>No memory bank found.</CommandEmpty>
<CommandEmpty>No memory banks yet.</CommandEmpty>
<CommandGroup>
{sortedBanks.map((bank) => (
<CommandItem
@@ -165,34 +176,73 @@ function BankSelectorInner() {
))}
</CommandGroup>
</CommandList>
{/* Footer: Create new bank */}
<div className="border-t border-border p-1">
<button
className="w-full flex items-center gap-2 px-2 py-2 text-sm rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={() => {
setOpen(false);
setCreateDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
<span>Create new bank</span>
</button>
</div>
</Command>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-primary hover:bg-accent gap-1.5"
onClick={() => setCreateDialogOpen(true)}
title="Create new memory bank"
>
<Plus className="h-4 w-4" />
<span>New Bank</span>
</Button>
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Add Document Button */}
{currentBank && (
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-secondary hover:bg-secondary/20 gap-1.5"
className="h-9 gap-1.5"
onClick={() => setDocDialogOpen(true)}
title="Add document to current bank"
>
<FileText className="h-4 w-4" />
<span>New Document</span>
<Plus className="h-4 w-4" />
<span>Add Document</span>
</Button>
)}
{/* Spacer */}
<div className="flex-1" />
{/* GitHub Link */}
<a
href="https://github.com/vectorize-io/hindsight"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="View on GitHub"
>
<Github className="h-5 w-5" />
<span className="text-sm font-medium">GitHub</span>
</a>
{/* Separator */}
<div className="h-8 w-px bg-border" />
{/* Dark Mode Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-9 w-9"
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
@@ -333,17 +383,32 @@ function BankSelectorInner() {
export function BankSelector() {
return (
<Suspense fallback={
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary">
<div className="flex items-center gap-2.5 text-sm">
<span className="font-medium">Memory Bank:</span>
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary-gradient">
<div className="flex items-center gap-4 text-sm">
<Image src="/logo.png" alt="Hindsight" width={40} height={40} className="h-10 w-auto" unoptimized />
<div className="h-8 w-px bg-border" />
<Button
variant="outline"
className="w-[300px] justify-between font-bold border-2 border-primary"
className="w-[250px] justify-between font-bold border-2 border-primary"
disabled
>
Loading...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<div className="flex-1" />
<a
href="https://github.com/vectorize-io/hindsight"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground"
>
<Github className="h-5 w-5" />
<span className="text-sm font-medium">GitHub</span>
</a>
<div className="h-8 w-px bg-border" />
<Button variant="ghost" size="icon" className="h-9 w-9" disabled>
<Moon className="h-5 w-5" />
</Button>
</div>
</div>
}>
@@ -1,15 +1,17 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { client } from '@/lib/api';
import { useBank } from '@/lib/bank-context';
import cytoscape from 'cytoscape';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Copy, Check, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Copy, Check, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings2, Eye, EyeOff } from 'lucide-react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { MemoryDetailPanel } from './memory-detail-panel';
import { Graph2D, convertHindsightGraphData, GraphNode } from './graph-2d';
type FactType = 'world' | 'experience' | 'opinion';
type ViewMode = 'graph' | 'table' | 'timeline';
@@ -23,16 +25,41 @@ export function DataView({ factType }: DataViewProps) {
const [viewMode, setViewMode] = useState<ViewMode>('graph');
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [nodeLimit, setNodeLimit] = useState(50);
const [layout, setLayout] = useState('circle');
const [searchQuery, setSearchQuery] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [selectedTableMemory, setSelectedTableMemory] = useState<any>(null);
const itemsPerPage = 100;
const cyRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState<number | undefined>(50);
const [showControlPanel, setShowControlPanel] = useState(true);
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(new Set(['semantic', 'temporal', 'entity', 'causal']));
const toggleLinkType = (type: string) => {
setVisibleLinkTypes(prev => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
// Esc key handler to deselect graph node
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedGraphNode) {
setSelectedGraphNode(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedGraphNode]);
const copyToClipboard = async (text: string) => {
try {
@@ -68,118 +95,98 @@ export function DataView({ factType }: DataViewProps) {
}
};
const renderGraph = () => {
if (!data || !containerRef.current || !data.nodes || !data.edges) return;
// Filter table rows based on search query (text only)
const filteredTableRows = useMemo(() => {
if (!data?.table_rows) return [];
if (!searchQuery) return data.table_rows;
if (cyRef.current) {
cyRef.current.destroy();
}
const limitedNodes = (data.nodes || []).slice(0, nodeLimit);
const nodeIds = new Set(limitedNodes.map((n: any) => n.data.id));
const limitedEdges = (data.edges || []).filter((e: any) =>
nodeIds.has(e.data.source) && nodeIds.has(e.data.target)
const query = searchQuery.toLowerCase();
return data.table_rows.filter((row: any) =>
row.text?.toLowerCase().includes(query)
);
}, [data, searchQuery]);
const layouts: any = {
circle: {
name: 'circle',
animate: false,
radius: 300,
spacingFactor: 1.5,
},
grid: {
name: 'grid',
animate: false,
rows: Math.ceil(Math.sqrt(limitedNodes.length)),
cols: Math.ceil(Math.sqrt(limitedNodes.length)),
spacingFactor: 2,
},
cose: {
name: 'cose',
animate: false,
nodeRepulsion: 15000,
idealEdgeLength: 150,
edgeElasticity: 100,
nestingFactor: 1.2,
gravity: 1,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0,
},
};
// Get filtered node IDs for graph filtering
const filteredNodeIds = useMemo(() => {
return new Set(filteredTableRows.map((row: any) => row.id));
}, [filteredTableRows]);
cyRef.current = cytoscape({
container: containerRef.current,
elements: [
...limitedNodes.map((n: any) => ({ data: n.data })),
...limitedEdges.map((e: any) => ({ data: e.data })),
],
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)' as any,
label: 'data(label)' as any,
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
'font-weight': 'bold',
'text-wrap': 'wrap',
'text-max-width': '100px',
width: 40,
height: 40,
'border-width': 2,
'border-color': '#333',
},
},
{
selector: 'edge',
style: {
width: 1,
'line-color': 'data(color)' as any,
'line-style': 'data(lineStyle)' as any,
'target-arrow-shape': 'triangle',
'target-arrow-color': 'data(color)' as any,
'curve-style': 'bezier',
opacity: 0.6,
},
},
{
selector: 'node:selected',
style: {
'border-width': 4,
'border-color': '#000',
},
},
] as any,
layout: layouts[layout] || layouts.circle,
});
// Add click handler for nodes
cyRef.current.on('tap', 'node', (evt: any) => {
const nodeId = evt.target.id();
// Find the corresponding table row data
const nodeData = data.table_rows?.find((row: any) => row.id === nodeId);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
});
// Click on background to deselect
cyRef.current.on('tap', (evt: any) => {
if (evt.target === cyRef.current) {
setSelectedGraphNode(null);
}
});
// Helper to get normalized link type
const getLinkTypeCategory = (type: string | undefined): string => {
if (!type) return 'semantic';
if (type === 'semantic' || type === 'temporal' || type === 'entity') return type;
if (['causes', 'caused_by', 'enables', 'prevents'].includes(type)) return 'causal';
return 'semantic';
};
useEffect(() => {
if (viewMode === 'graph' && data) {
renderGraph();
// Convert data for Graph2D with filtering
const graph2DData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
const fullData = convertHindsightGraphData(data);
let nodes = fullData.nodes;
let links = fullData.links;
// Filter nodes based on search query
if (searchQuery) {
const filteredNodes = fullData.nodes.filter(node => filteredNodeIds.has(node.id));
const filteredNodeIdSet = new Set(filteredNodes.map(n => n.id));
nodes = filteredNodes;
links = fullData.links.filter(link =>
filteredNodeIdSet.has(link.source) && filteredNodeIdSet.has(link.target)
);
}
}, [viewMode, data, nodeLimit, layout]);
// Filter links based on visible link types
links = links.filter(link => {
const category = getLinkTypeCategory(link.type);
return visibleLinkTypes.has(category);
});
return { nodes, links };
}, [data, searchQuery, filteredNodeIds, visibleLinkTypes]);
// Calculate link stats for display
const linkStats = useMemo(() => {
let semantic = 0, temporal = 0, entity = 0, causal = 0, total = 0;
const otherTypes: Record<string, number> = {};
graph2DData.links.forEach(l => {
total++;
const type = l.type || 'unknown';
if (type === 'semantic') semantic++;
else if (type === 'temporal') temporal++;
else if (type === 'entity') entity++;
else if (type === 'causes' || type === 'caused_by' || type === 'enables' || type === 'prevents') causal++;
else {
otherTypes[type] = (otherTypes[type] || 0) + 1;
}
});
console.log('Graph link stats:', { semantic, temporal, entity, causal, total });
if (Object.keys(otherTypes).length > 0) {
console.log('Other link types:', otherTypes);
}
return { semantic, temporal, entity, causal, total, otherTypes };
}, [graph2DData]);
// Handle node click in graph - show in panel
const handleGraphNodeClick = useCallback((node: GraphNode) => {
const nodeData = data?.table_rows?.find((row: any) => row.id === node.id);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
}, [data]);
// Memoized color functions to prevent graph re-initialization
// Uses brand colors: primary blue (#0074d9), teal (#009296), amber for entity, purple for causal
const nodeColorFn = useCallback((node: GraphNode) => node.color || '#0074d9', []);
const linkColorFn = useCallback((link: any) => {
if (link.type === 'temporal') return '#009296'; // Brand teal
if (link.type === 'entity') return '#f59e0b'; // Amber
if (link.type === 'causes' || link.type === 'caused_by' || link.type === 'enables' || link.type === 'prevents') {
return '#8b5cf6'; // Purple for causal
}
return '#0074d9'; // Brand primary blue for semantic
}, []);
// Reset to first page when search query changes
useEffect(() => {
@@ -204,9 +211,20 @@ export function DataView({ factType }: DataViewProps) {
</div>
) : data ? (
<>
{/* Always visible filter */}
<div className="mb-4">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter memories by text..."
className="max-w-md"
/>
</div>
<div className="flex items-center justify-between mb-6">
<div className="text-sm text-muted-foreground">
{data.total_units} total memories
{searchQuery ? `${filteredTableRows.length} of ${data.total_units} memories` : `${data.total_units} total memories`}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
<button
@@ -243,134 +261,214 @@ export function DataView({ factType }: DataViewProps) {
</div>
{viewMode === 'graph' && (
<div className="flex gap-4">
<div className={`relative transition-all ${selectedGraphNode ? 'w-2/3' : 'w-full'}`}>
<div className="p-4 bg-card border-b-2 border-primary flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Limit nodes:</label>
<Input
type="number"
value={nodeLimit}
onChange={(e) => setNodeLimit(parseInt(e.target.value))}
min="10"
max="1000"
step="10"
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Layout:</label>
<Select value={layout} onValueChange={setLayout}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="circle">Circle (fast)</SelectItem>
<SelectItem value="grid">Grid (fast)</SelectItem>
<SelectItem value="cose">Force-directed (slow)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground ml-auto">
Click on a node to view details
</div>
</div>
<div ref={containerRef} className="w-full h-[800px] bg-background" />
<div className="absolute top-20 left-5 bg-card p-4 border-2 border-primary rounded-lg shadow-lg max-w-[250px]">
<h3 className="font-bold mb-2 border-b-2 border-primary pb-1 text-card-foreground">Legend</h3>
<h4 className="font-bold mt-2 mb-1 text-sm text-card-foreground">Link Types:</h4>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-cyan-500 border-t border-dashed border-cyan-500" />
<span className="text-sm"><strong>Temporal</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-pink-500" />
<span className="text-sm"><strong>Semantic</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-yellow-500" />
<span className="text-sm"><strong>Entity</strong></span>
</div>
<h4 className="font-bold mt-2 mb-1 text-sm">Nodes:</h4>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-gray-300 border border-gray-500 rounded" />
<span className="text-sm">No entities</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-300 border border-gray-500 rounded" />
<span className="text-sm">1 entity</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-500 border border-gray-500 rounded" />
<span className="text-sm">2+ entities</span>
</div>
</div>
<div className="flex gap-0">
{/* Graph */}
<div className="flex-1 min-w-0">
<Graph2D
data={graph2DData}
height={700}
showLabels={showLabels}
onNodeClick={handleGraphNodeClick}
maxNodes={maxNodes}
nodeColorFn={nodeColorFn}
linkColorFn={linkColorFn}
/>
</div>
{/* Memory Detail Panel for Graph View - Fixed on Right */}
{selectedGraphNode && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
/>
{/* Right Toggle Button */}
<button
onClick={() => setShowControlPanel(!showControlPanel)}
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
title={showControlPanel ? 'Hide panel' : 'Show panel'}
>
{showControlPanel ? (
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
) : (
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
)}
</button>
{/* Right Panel - Legend/Controls OR Memory Details */}
<div className={`${showControlPanel ? 'w-80' : 'w-0'} transition-all duration-300 overflow-hidden flex-shrink-0`}>
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
{selectedGraphNode ? (
/* Memory Detail View */
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
inPanel
/>
) : (
/* Legend & Controls View */
<div className="p-4 space-y-5">
{/* Legend & Stats */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Graph</h3>
<div className="space-y-2">
{/* Nodes */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: '#0074d9' }} />
<span className="text-foreground">Nodes</span>
</div>
<span className="font-mono text-foreground">
{Math.min(maxNodes ?? graph2DData.nodes.length, graph2DData.nodes.length)}/{graph2DData.nodes.length}
</span>
</div>
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">Links ({linkStats.total}) <span className="text-muted-foreground/60">· click to filter</span></div>
<button
onClick={() => toggleLinkType('semantic')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('semantic') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#0074d9]" />
<span className="text-foreground">Semantic</span>
</div>
<span className={`font-mono ${linkStats.semantic === 0 ? 'text-destructive' : 'text-foreground'}`}>
{linkStats.semantic}
</span>
</button>
<button
onClick={() => toggleLinkType('temporal')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('temporal') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#009296]" />
<span className="text-foreground">Temporal</span>
</div>
<span className={`font-mono ${linkStats.temporal === 0 ? 'text-destructive' : 'text-foreground'}`}>
{linkStats.temporal}
</span>
</button>
<button
onClick={() => toggleLinkType('entity')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('entity') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#f59e0b]" />
<span className="text-foreground">Entity</span>
</div>
<span className="font-mono text-foreground">{linkStats.entity}</span>
</button>
<button
onClick={() => toggleLinkType('causal')}
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
visibleLinkTypes.has('causal') ? 'hover:bg-muted' : 'opacity-40 hover:opacity-60'
}`}
>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
<span className="text-foreground">Causal</span>
</div>
<span className={`font-mono ${linkStats.causal === 0 ? 'text-muted-foreground' : 'text-foreground'}`}>
{linkStats.causal}
</span>
</button>
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground capitalize ml-6">{type}</span>
<span className="font-mono text-muted-foreground">{count as number}</span>
</div>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Controls Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Display</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="show-labels" className="text-sm text-foreground">Show labels</Label>
<Switch
id="show-labels"
checked={showLabels}
onCheckedChange={setShowLabels}
/>
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Limits Section */}
<div>
<h3 className="text-sm font-semibold mb-3 text-foreground">Performance</h3>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm text-foreground">Max nodes</Label>
<span className="text-xs text-muted-foreground">
{maxNodes ?? 'All'} / {graph2DData.nodes.length}
</span>
</div>
<Slider
value={[maxNodes ?? graph2DData.nodes.length]}
min={10}
max={Math.max(graph2DData.nodes.length, 10)}
step={10}
onValueChange={([v]) => setMaxNodes(v >= graph2DData.nodes.length ? undefined : v)}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground">
All links between visible nodes are shown.
</p>
</div>
</div>
<div className="border-t border-border" />
{/* Hint */}
<div className="text-xs text-muted-foreground/60 text-center pt-2">
Click a node to see details
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{viewMode === 'table' && (
<div>
<div className="w-full">
<div className="px-5 mb-4">
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search memories (text, context, ID)..."
className="max-w-2xl"
/>
</div>
<div className="px-5 pb-5">
{data.table_rows && data.table_rows.length > 0 ? (
<div className="pb-4">
{filteredTableRows.length > 0 ? (
(() => {
const filteredRows = data.table_rows.filter((row: any) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query) ||
row.id?.toLowerCase().includes(query)
);
});
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const totalPages = Math.ceil(filteredTableRows.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, endIndex);
const paginatedRows = filteredTableRows.slice(startIndex, endIndex);
return (
<>
<div className="border rounded-lg overflow-hidden">
<Table>
<Table className="table-fixed">
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[80px]">ID</TableHead>
<TableHead>Text</TableHead>
<TableHead className="w-[150px]">Context</TableHead>
<TableHead className="w-[100px]">Occurred</TableHead>
<TableHead className="w-[100px]">Mentioned</TableHead>
<TableHead className="w-[60px]">Actions</TableHead>
<TableHead className="w-[45%]">Memory</TableHead>
<TableHead className="w-[20%]">Entities</TableHead>
<TableHead className="w-[15%]">Occurred</TableHead>
<TableHead className="w-[15%]">Mentioned</TableHead>
<TableHead className="w-[5%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedRows.map((row: any, idx: number) => {
const occurredDisplay = row.occurred_start
? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: null;
const mentionedDisplay = row.mentioned_at
? new Date(row.mentioned_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
? new Date(row.mentioned_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: null;
return (
@@ -381,46 +479,40 @@ export function DataView({ factType }: DataViewProps) {
selectedTableMemory?.id === row.id ? 'bg-primary/10' : ''
}`}
>
<TableCell className="font-mono text-xs text-muted-foreground" title={row.id}>
{row.id?.substring(0, 8)}...
<TableCell className="py-2">
<div className="line-clamp-2 text-sm leading-snug">{row.text}</div>
{row.context && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{row.context}</div>
)}
</TableCell>
<TableCell>
<div className="line-clamp-2 text-sm">{row.text}</div>
{row.entities && (
<div className="flex gap-1 mt-1 flex-wrap">
{row.entities.split(', ').slice(0, 3).map((entity: string, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-secondary-foreground">
<TableCell className="py-2">
{row.entities ? (
<div className="flex gap-1 flex-wrap">
{row.entities.split(', ').slice(0, 2).map((entity: string, i: number) => (
<span
key={i}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium"
>
{entity}
</span>
))}
{row.entities.split(', ').length > 3 && (
{row.entities.split(', ').length > 2 && (
<span className="text-[10px] text-muted-foreground">
+{row.entities.split(', ').length - 3}
+{row.entities.split(', ').length - 2}
</span>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[150px]" title={row.context}>
{row.context || '-'}
<TableCell className="text-xs py-2">
{occurredDisplay || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-xs">
{occurredDisplay ? (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{occurredDisplay}
</span>
) : '-'}
<TableCell className="text-xs py-2">
{mentionedDisplay || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-xs">
{mentionedDisplay ? (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{mentionedDisplay}
</span>
) : '-'}
</TableCell>
<TableCell>
<TableCell className="py-2">
<Button
onClick={(e) => {
e.stopPropagation();
@@ -428,7 +520,7 @@ export function DataView({ factType }: DataViewProps) {
}}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
className="h-6 w-6 p-0"
title="Copy ID"
>
{copiedId === row.id ? (
@@ -447,9 +539,9 @@ export function DataView({ factType }: DataViewProps) {
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(endIndex, filteredRows.length)} of {filteredRows.length}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<div className="text-xs text-muted-foreground">
{startIndex + 1}-{Math.min(endIndex, filteredTableRows.length)} of {filteredTableRows.length}
</div>
<div className="flex items-center gap-1">
<Button
@@ -457,20 +549,20 @@ export function DataView({ factType }: DataViewProps) {
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronsLeft className="h-4 w-4" />
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-sm px-3">
<span className="text-xs px-2">
{currentPage} / {totalPages}
</span>
<Button
@@ -478,18 +570,18 @@ export function DataView({ factType }: DataViewProps) {
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
>
<ChevronsRight className="h-4 w-4" />
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
</div>
@@ -499,7 +591,7 @@ export function DataView({ factType }: DataViewProps) {
})()
) : (
<div className="text-center py-12 text-muted-foreground">
{data.table_rows ? 'No memories match your search' : 'No memories found'}
{data.table_rows?.length > 0 ? 'No memories match your filter' : 'No memories found'}
</div>
)}
</div>
@@ -519,7 +611,7 @@ export function DataView({ factType }: DataViewProps) {
)}
{viewMode === 'timeline' && (
<TimelineView data={data} />
<TimelineView data={data} filteredRows={filteredTableRows} />
)}
</>
) : (
@@ -537,17 +629,17 @@ export function DataView({ factType }: DataViewProps) {
// Timeline View Component - Custom compact timeline with zoom and navigation
type Granularity = 'year' | 'month' | 'week' | 'day';
function TimelineView({ data }: { data: any }) {
function TimelineView({ data, filteredRows }: { data: any; filteredRows: any[] }) {
const [selectedItem, setSelectedItem] = useState<any>(null);
const [granularity, setGranularity] = useState<Granularity>('month');
const [currentIndex, setCurrentIndex] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null);
// Filter and sort items that have occurred_start dates
// Filter and sort items that have occurred_start dates (using filtered data)
const { sortedItems, itemsWithoutDates } = useMemo(() => {
if (!data?.table_rows) return { sortedItems: [], itemsWithoutDates: [] };
if (!filteredRows || filteredRows.length === 0) return { sortedItems: [], itemsWithoutDates: [] };
const withDates = data.table_rows
const withDates = filteredRows
.filter((row: any) => row.occurred_start)
.sort((a: any, b: any) => {
const dateA = new Date(a.occurred_start).getTime();
@@ -555,19 +647,10 @@ function TimelineView({ data }: { data: any }) {
return dateA - dateB;
});
const withoutDates = data.table_rows.filter((row: any) => !row.occurred_start);
// Debug logging
console.log('Timeline data:', {
total: data.table_rows.length,
withDates: withDates.length,
withoutDates: withoutDates.length,
sampleWithDate: withDates[0],
sampleWithoutDate: withoutDates[0]
});
const withoutDates = filteredRows.filter((row: any) => !row.occurred_start);
return { sortedItems: withDates, itemsWithoutDates: withoutDates };
}, [data]);
}, [filteredRows]);
// Group items by granularity
const timelineGroups = useMemo(() => {
@@ -697,9 +780,9 @@ function TimelineView({ data }: { data: any }) {
};
return (
<div className="flex gap-3 px-4">
<div className="px-4">
{/* Timeline */}
<div className={`transition-all ${selectedItem ? 'w-2/3' : 'w-full'}`}>
<div>
{/* Controls */}
<div className="flex items-center justify-between mb-3 gap-4">
<div className="text-xs text-muted-foreground">
@@ -853,7 +936,7 @@ function TimelineView({ data }: { data: any }) {
{item.entities && (
<div className="flex gap-1 mt-1 flex-wrap">
{item.entities.split(', ').slice(0, 3).map((entity: string, i: number) => (
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-secondary text-secondary-foreground">
<span key={i} className="text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{entity}
</span>
))}
@@ -0,0 +1,599 @@
'use client';
import { useRef, useEffect, useState, useMemo } from 'react';
import cytoscape, { Core, NodeSingular } from 'cytoscape';
// Hook to detect dark mode
function useIsDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const checkDark = () => {
setIsDark(document.documentElement.classList.contains('dark'));
};
checkDark();
// Watch for theme changes
const observer = new MutationObserver(checkDark);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface GraphNode {
id: string;
label?: string;
color?: string;
size?: number;
group?: string;
metadata?: Record<string, any>;
}
export interface GraphLink {
source: string;
target: string;
color?: string;
width?: number;
type?: string;
entity?: string;
weight?: number;
metadata?: Record<string, any>;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface Graph2DProps {
data: GraphData;
height?: number;
showLabels?: boolean;
onNodeClick?: (node: GraphNode) => void;
onNodeHover?: (node: GraphNode | null) => void;
nodeColorFn?: (node: GraphNode) => string;
nodeSizeFn?: (node: GraphNode) => number;
linkColorFn?: (link: GraphLink) => string;
linkWidthFn?: (link: GraphLink) => number;
maxNodes?: number;
}
// ============================================================================
// Default Values
// ============================================================================
// Brand colors
const BRAND_PRIMARY = '#0074d9';
const BRAND_TEAL = '#009296';
const LINK_SEMANTIC = '#0074d9'; // Primary blue for semantic
const LINK_TEMPORAL = '#009296'; // Teal for temporal
const LINK_ENTITY = '#f59e0b'; // Amber for entity
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
const DEFAULT_NODE_SIZE = 20;
const DEFAULT_LINK_WIDTH = 1;
// ============================================================================
// Component
// ============================================================================
export function Graph2D({
data,
height = 600,
showLabels = true,
onNodeClick,
onNodeHover,
nodeColorFn,
nodeSizeFn,
linkColorFn,
linkWidthFn,
maxNodes,
}: Graph2DProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<Core | null>(null);
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isDarkMode = useIsDarkMode();
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
const onNodeClickRef = useRef(onNodeClick);
const onNodeHoverRef = useRef(onNodeHover);
const fullDataRef = useRef(data);
const nodeColorFnRef = useRef(nodeColorFn);
const linkColorFnRef = useRef(linkColorFn);
onNodeClickRef.current = onNodeClick;
onNodeHoverRef.current = onNodeHover;
fullDataRef.current = data;
nodeColorFnRef.current = nodeColorFn;
linkColorFnRef.current = linkColorFn;
// Transform and limit data - only limit nodes, show ALL links between visible nodes
const graphData = useMemo(() => {
let nodes = [...data.nodes];
// Limit nodes if needed
if (maxNodes && nodes.length > maxNodes) {
nodes = nodes.slice(0, maxNodes);
}
// Show ALL links between visible nodes (no random link limiting)
const nodeIds = new Set(nodes.map(n => n.id));
const links = data.links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links };
}, [data, maxNodes]);
// Convert to Cytoscape format
const cyElements = useMemo(() => {
const nodes = graphData.nodes.map(node => ({
data: {
id: node.id,
label: node.label || node.id.substring(0, 8),
color: nodeColorFn ? nodeColorFn(node) : (node.color || DEFAULT_NODE_COLOR),
size: nodeSizeFn ? nodeSizeFn(node) : (node.size || DEFAULT_NODE_SIZE),
originalNode: node,
},
}));
const edges = graphData.links.map((link, idx) => ({
data: {
id: `edge-${idx}`,
source: link.source,
target: link.target,
color: linkColorFn ? linkColorFn(link) : (link.color || DEFAULT_LINK_COLOR),
width: linkWidthFn ? linkWidthFn(link) : (link.width || DEFAULT_LINK_WIDTH),
type: link.type,
entity: link.entity,
weight: link.weight,
originalLink: link,
},
}));
return [...nodes, ...edges];
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
// Initialize Cytoscape
useEffect(() => {
if (!containerRef.current) return;
// Handle empty data case
if (cyElements.length === 0) {
setIsLoading(false);
return;
}
setIsLoading(true);
// Theme-aware colors
const textColor = isDarkMode ? '#ffffff' : '#1f2937';
const textBgColor = isDarkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)';
const borderColor = isDarkMode ? '#ffffff' : '#374151';
const cy = cytoscape({
container: containerRef.current,
elements: cyElements,
style: [
{
selector: 'node',
style: {
'background-fill': 'radial-gradient',
'background-gradient-stop-colors': ['#0074d9', '#005bb5'],
'background-gradient-stop-positions': ['0%', '100%'],
'width': 'data(size)',
'height': 'data(size)',
'label': showLabels ? 'data(label)' : '',
'color': textColor,
'text-valign': 'bottom',
'text-halign': 'center',
'font-size': '8px',
'font-weight': 500,
'text-margin-y': 3,
'text-wrap': 'wrap',
'text-max-width': '80px',
'text-background-color': textBgColor,
'text-background-opacity': 0.9,
'text-background-padding': '2px',
'text-background-shape': 'roundrectangle',
'border-width': 0,
'z-index': 0,
},
},
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#0074d9',
'border-opacity': 1,
},
},
{
selector: 'node:active',
style: {
'overlay-opacity': 0,
},
},
{
selector: 'edge',
style: {
'width': 'data(width)',
'line-color': 'data(color)',
'target-arrow-color': 'data(color)',
'curve-style': 'bezier',
'opacity': isDarkMode ? 0.5 : 0.6,
'z-index': 1,
},
},
{
selector: 'edge:selected',
style: {
'opacity': 1,
'width': 3,
},
},
// Dimmed state for non-selected elements
{
selector: '.dimmed',
style: {
'opacity': 0.15,
},
},
// Highlighted state for selected node and neighbors
{
selector: 'node.highlighted',
style: {
'opacity': 1,
'border-width': 3,
'border-color': '#0074d9',
'border-opacity': 1,
},
},
{
selector: 'edge.highlighted',
style: {
'opacity': 0.9,
'width': 2,
},
},
],
layout: {
name: 'cose',
animate: false,
randomize: true,
nodeRepulsion: () => 100000,
idealEdgeLength: () => 300,
edgeElasticity: () => 20,
nestingFactor: 0.1,
gravity: 0.01,
numIter: 2500,
coolingFactor: 0.95,
minTemp: 1.0,
nodeOverlap: 20,
nodeDimensionsIncludeLabels: true,
padding: 50,
} as any,
minZoom: 0.1,
maxZoom: 5,
wheelSensitivity: 0.3,
});
cyRef.current = cy;
// Event handlers
cy.on('tap', 'node', (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data('originalNode') as GraphNode;
if (onNodeClickRef.current && originalNode) {
onNodeClickRef.current(originalNode);
}
// Find ALL connected nodes from full data (not just visible ones)
const fullData = fullDataRef.current;
const clickedNodeId = originalNode.id;
// Find all links connected to this node from full data
const connectedLinks = fullData.links.filter(
l => l.source === clickedNodeId || l.target === clickedNodeId
);
// Find all connected node IDs
const connectedNodeIds = new Set<string>();
connectedLinks.forEach(l => {
connectedNodeIds.add(l.source);
connectedNodeIds.add(l.target);
});
// Add any missing nodes to the graph
const existingNodeIds = new Set(cy.nodes().map(n => n.id()));
const nodesToAdd: any[] = [];
const edgesToAdd: any[] = [];
connectedNodeIds.forEach(nodeId => {
if (!existingNodeIds.has(nodeId)) {
const nodeData = fullData.nodes.find(n => n.id === nodeId);
if (nodeData) {
nodesToAdd.push({
group: 'nodes',
data: {
id: nodeData.id,
label: nodeData.label || nodeData.id.substring(0, 8),
color: nodeColorFnRef.current ? nodeColorFnRef.current(nodeData) : (nodeData.color || DEFAULT_NODE_COLOR),
size: nodeData.size || DEFAULT_NODE_SIZE,
originalNode: nodeData,
isTemporary: true, // Mark as temporarily added
},
});
}
}
});
// Add missing edges
const existingEdgeIds = new Set(cy.edges().map(e => `${e.data('source')}-${e.data('target')}`));
connectedLinks.forEach((link, idx) => {
const edgeKey = `${link.source}-${link.target}`;
const reverseKey = `${link.target}-${link.source}`;
if (!existingEdgeIds.has(edgeKey) && !existingEdgeIds.has(reverseKey)) {
edgesToAdd.push({
group: 'edges',
data: {
id: `temp-edge-${idx}-${Date.now()}`,
source: link.source,
target: link.target,
color: linkColorFnRef.current ? linkColorFnRef.current(link) : (link.color || DEFAULT_LINK_COLOR),
width: link.width || DEFAULT_LINK_WIDTH,
type: link.type,
isTemporary: true,
},
});
}
});
// Add new elements to graph
if (nodesToAdd.length > 0 || edgesToAdd.length > 0) {
cy.add([...nodesToAdd, ...edgesToAdd]);
// Position new nodes near the clicked node
const clickedPos = node.position();
cy.nodes('[?isTemporary]').forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodesToAdd.length;
const radius = 150;
n.position({
x: clickedPos.x + radius * Math.cos(angle),
y: clickedPos.y + radius * Math.sin(angle),
});
});
}
// Get all connected elements (including newly added)
const neighborhood = node.neighborhood().add(node);
// Dim all elements first
cy.elements().addClass('dimmed');
// Highlight the neighborhood
neighborhood.removeClass('dimmed');
neighborhood.addClass('highlighted');
// Center on the neighborhood without changing positions
cy.animate({
fit: { eles: neighborhood, padding: 50 },
}, { duration: 400 });
});
// Click on background to reset
cy.on('tap', (evt) => {
if (evt.target === cy) {
// Remove temporary nodes and edges
cy.elements('[?isTemporary]').remove();
cy.elements().removeClass('dimmed highlighted');
cy.animate({
fit: { eles: cy.elements(), padding: 50 },
}, { duration: 400 });
}
});
cy.on('mouseover', 'node', (evt) => {
const node = evt.target as NodeSingular;
const originalNode = node.data('originalNode') as GraphNode;
setHoveredNode(originalNode);
if (onNodeHoverRef.current && originalNode) {
onNodeHoverRef.current(originalNode);
}
containerRef.current!.style.cursor = 'pointer';
});
cy.on('mouseout', 'node', () => {
setHoveredNode(null);
if (onNodeHoverRef.current) {
onNodeHoverRef.current(null);
}
containerRef.current!.style.cursor = 'default';
});
// Edge hover handlers
cy.on('mouseover', 'edge', (evt) => {
const edge = evt.target;
const originalLink = edge.data('originalLink') as GraphLink;
if (originalLink) {
setHoveredLink(originalLink);
// Get position for tooltip
const renderedPos = edge.renderedMidpoint();
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
}
containerRef.current!.style.cursor = 'pointer';
});
cy.on('mouseout', 'edge', () => {
setHoveredLink(null);
setLinkTooltipPos(null);
containerRef.current!.style.cursor = 'default';
});
// Run layout
cy.layout({
name: 'cose',
animate: false,
randomize: true,
nodeRepulsion: () => 100000,
idealEdgeLength: () => 300,
edgeElasticity: () => 20,
nestingFactor: 0.1,
gravity: 0.01,
numIter: 2500,
coolingFactor: 0.95,
minTemp: 1.0,
nodeOverlap: 20,
nodeDimensionsIncludeLabels: true,
padding: 50,
} as any).run();
// Fit to viewport
cy.fit(undefined, 50);
setIsLoading(false);
return () => {
cy.destroy();
};
}, [cyElements, showLabels, isDarkMode]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (cyRef.current) {
cyRef.current.resize();
cyRef.current.fit(undefined, 50);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div className="relative w-full rounded-lg overflow-hidden border border-border" style={{ height }}>
{/* Loading state */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
</div>
)}
{/* Cytoscape container */}
<div
ref={containerRef}
className="w-full h-full"
style={{
background: isDarkMode
? 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)'
: 'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)',
backgroundSize: '20px 20px',
backgroundColor: isDarkMode ? '#0f1419' : '#f8fafc',
}}
/>
{/* Empty state */}
{!isLoading && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">No memories to display</p>
</div>
</div>
)}
{/* Link hover tooltip */}
{hoveredLink && linkTooltipPos && (
<div
className="absolute z-30 pointer-events-none"
style={{
left: linkTooltipPos.x,
top: linkTooltipPos.y,
transform: 'translate(-50%, -100%) translateY(-8px)',
}}
>
<div className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
isDarkMode ? 'bg-gray-800 text-white' : 'bg-white text-gray-900 border border-gray-200'
}`}>
<div className="font-medium capitalize mb-1">
{(() => {
const type = hoveredLink.type || 'semantic';
if (['causes', 'caused_by', 'enables', 'prevents'].includes(type)) {
return `Causal (${type.replace('_', ' ')})`;
}
return `${type} link`;
})()}
</div>
{hoveredLink.entity && (
<div className="text-xs opacity-80">
Entity: <span className="font-medium">{hoveredLink.entity}</span>
</div>
)}
{hoveredLink.weight !== undefined && (
<div className="text-xs opacity-80">
Weight: <span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
</div>
)}
</div>
</div>
)}
{/* Controls hint */}
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
Drag to pan Scroll to zoom Click node to focus
</div>
</div>
);
}
// ============================================================================
// Utility Functions
// ============================================================================
export function convertHindsightGraphData(hindsightData: {
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
edges?: Array<{ data: { source: string; target: string; color?: string; lineStyle?: string; linkType?: string; entityName?: string; weight?: number; similarity?: number } }>;
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
}): GraphData {
const nodes: GraphNode[] = (hindsightData.nodes || []).map(n => {
const tableRow = hindsightData.table_rows?.find(r => r.id === n.data.id);
// Use memory text as label, truncated to ~40 chars
let label = n.data.label;
if (!label && tableRow?.text) {
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + '...' : tableRow.text;
}
if (!label) {
label = n.data.id.substring(0, 8);
}
return {
id: n.data.id,
label,
color: n.data.color,
metadata: tableRow,
};
});
const links: GraphLink[] = (hindsightData.edges || []).map(e => ({
source: e.data.source,
target: e.data.target,
color: e.data.color,
// Use linkType directly from API, fallback to lineStyle check, default to semantic
type: e.data.linkType || (e.data.lineStyle === 'dashed' ? 'temporal' : 'semantic'),
entity: e.data.entityName, // API returns entityName
weight: e.data.weight ?? e.data.similarity,
}));
return { nodes, links };
}
@@ -64,13 +64,12 @@ export function MemoryDetailPanel({
<p className="text-sm text-muted-foreground mt-1">Full memory content and metadata</p>
</div>
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={onClose}
className="h-9 px-3 gap-2"
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
Close
<X className="h-5 w-5" />
</Button>
</div>
File diff suppressed because it is too large Load Diff
@@ -15,16 +15,16 @@ interface SidebarProps {
export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
const { currentBank } = useBank();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
if (!currentBank) {
return null;
}
const navItems = [
{ id: 'data' as NavItem, label: 'Memories', icon: Database },
{ id: 'recall' as NavItem, label: 'Recall', icon: Search },
{ id: 'reflect' as NavItem, label: 'Reflect', icon: Sparkles },
{ id: 'data' as NavItem, label: 'Memories', icon: Database },
{ id: 'documents' as NavItem, label: 'Documents', icon: FileText },
{ id: 'entities' as NavItem, label: 'Entities', icon: Users },
{ id: 'profile' as NavItem, label: 'Memory Bank', icon: Box },
@@ -35,24 +35,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
'bg-card border-r border-border flex flex-col transition-all duration-300',
isCollapsed ? 'w-16' : 'w-64'
)}>
<div className="p-4 border-b border-border flex items-center justify-between">
{!isCollapsed && (
<h2 className="text-lg font-semibold text-card-foreground">Hindsight</h2>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 rounded-lg hover:bg-accent transition-colors ml-auto"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<ChevronLeft className="w-5 h-5" />
)}
</button>
</div>
<nav className="flex-1 p-3">
<nav className="flex-1 p-3 pt-4">
<ul className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
@@ -75,7 +58,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all',
isActive
? 'bg-primary text-primary-foreground shadow-sm'
? 'bg-primary-gradient text-white shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
isCollapsed && 'justify-center px-0'
)}
@@ -89,6 +72,27 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
})}
</ul>
</nav>
{/* Collapse/Expand button at bottom */}
<div className="p-3 border-t border-border">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
'w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors',
isCollapsed && 'justify-center px-0'
)}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? (
<ChevronRight className="w-5 h-5" />
) : (
<>
<ChevronLeft className="w-5 h-5" />
<span>Collapse</span>
</>
)}
</button>
</div>
</aside>
);
}
@@ -9,7 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary-gradient text-white hover:opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,46 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme') as Theme | null;
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = saved || (systemPrefersDark ? 'dark' : 'light');
setTheme(initialTheme);
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.classList.toggle('dark', newTheme === 'dark');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
@@ -317,7 +317,7 @@ If it's correct, set correct=true.
response_format=JudgeResponse,
scope="judge",
temperature=0,
max_tokens=4096
max_completion_tokens=4096
)
return judgement.correct, judgement.reasoning
@@ -398,7 +398,7 @@ Answer:
],
response_format=QuestionAnswer,
scope="memory",
max_tokens=32768,
max_completion_tokens=32768,
)
reasoning_text = answer_obj.reasoning or ""
if reasoning_text:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-dev"
version = "0.1.0"
version = "0.1.4"
description = "Development utilities for Hindsight"
requires-python = ">=3.11"
dependencies = [
+18 -186
View File
@@ -160,15 +160,14 @@ hindsight retain my-bank "Project deadline: April 15 (extended)" --document-id p
</TabItem>
</Tabs>
## List Documents
## Get Document
View all documents in a memory bank:
Retrieve a document's original text and metadata. This is useful for expanding document context after a recall operation returns memories with document references.
<Tabs>
<TabItem value="python" label="Python">
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
@@ -176,19 +175,16 @@ config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all documents
response = api.list_documents(bank_id="my-bank")
for doc in response.items:
print(f"{doc.id}: {doc.memory_unit_count} memories")
print(f" Created: {doc.created_at}")
# With pagination
response = api.list_documents(
# Get document to expand context from recall results
doc = api.get_document(
bank_id="my-bank",
limit=50,
offset=0
document_id="meeting-2024-03-15"
)
print(f"Document: {doc.id}")
print(f"Original text: {doc.original_text}")
print(f"Memory count: {doc.memory_unit_count}")
print(f"Created: {doc.created_at}")
```
</TabItem>
@@ -199,129 +195,28 @@ import { sdk, createClient, createConfig } from '@vectorize-io/hindsight-client'
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all documents
const response = await sdk.listDocuments({
client: apiClient,
path: { bank_id: 'my-bank' }
});
for (const doc of response.data.items) {
console.log(`${doc.id}: ${doc.memory_unit_count} memories`);
console.log(` Created: ${doc.created_at}`);
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# List documents
hindsight documents list my-bank
# With limit
hindsight documents list my-bank --limit 50
```
</TabItem>
</Tabs>
## Get Document Details
Retrieve a specific document with its content:
<Tabs>
<TabItem value="python" label="Python">
```python
# Get document
doc = api.get_document(
bank_id="my-bank",
document_id="meeting-2024-03-15"
)
print(f"Document: {doc.id}")
print(f"Original text: {doc.original_text[:200]}...")
print(f"Memories: {doc.memory_unit_count}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Get document
const doc = await sdk.getDocument({
// Get document to expand context from recall results
const { data: doc } = await sdk.getDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
});
console.log(`Document: ${doc.data.id}`);
console.log(`Original text: ${doc.data.original_text.substring(0, 200)}...`);
console.log(`Memories: ${doc.data.memory_unit_count}`);
console.log(`Document: ${doc.id}`);
console.log(`Original text: ${doc.original_text}`);
console.log(`Memory count: ${doc.memory_unit_count}`);
console.log(`Created: ${doc.created_at}`);
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Get document
hindsight documents get my-bank meeting-2024-03-15
```
</TabItem>
</Tabs>
## Delete Documents
Remove a document and all its memories:
<Tabs>
<TabItem value="python" label="Python">
```python
# Delete document (removes all associated memories)
api.delete_document(
bank_id="my-bank",
document_id="old-meeting"
)
# Bulk delete
for doc_id in ["old-1", "old-2", "old-3"]:
api.delete_document(bank_id="my-bank", document_id=doc_id)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Delete document
await sdk.deleteDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'old-meeting' }
});
// Bulk delete
for (const docId of ['old-1', 'old-2', 'old-3']) {
await sdk.deleteDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: docId }
});
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Delete document
hindsight documents delete my-bank old-meeting
# Confirm deletion
hindsight documents delete my-bank old-meeting --confirm
```
</TabItem>
</Tabs>
## Document Response Format
```json
@@ -329,76 +224,13 @@ hindsight documents delete my-bank old-meeting --confirm
"id": "meeting-2024-03-15",
"bank_id": "my-bank",
"original_text": "Alice presented the Q4 roadmap...",
"content_hash": "abc123def456",
"memory_unit_count": 12,
"created_at": "2024-03-15T14:00:00Z",
"retain_params": {
"context": "team meeting",
"event_date": "2024-03-15"
}
"updated_at": "2024-03-15T14:00:00Z"
}
```
## Use Cases
### Meeting Notes
<Tabs>
<TabItem value="python" label="Python">
```python
from datetime import date
# Store meeting notes with date-based IDs
client.retain(
bank_id="team-memory",
content=meeting_transcript,
document_id=f"meeting-{date.today()}"
)
```
</TabItem>
</Tabs>
### Documentation
<Tabs>
<TabItem value="python" label="Python">
```python
from pathlib import Path
# Store docs with version tracking
docs_dir = Path("docs")
version = "1.0"
for file in docs_dir.glob("*.md"):
client.retain(
bank_id="docs-memory",
content=file.read_text(),
document_id=f"docs-{file.stem}-v{version}"
)
```
</TabItem>
</Tabs>
### Conversation History
<Tabs>
<TabItem value="python" label="Python">
```python
# Store chat history with session IDs
client.retain(
bank_id="chat-memory",
content=conversation,
document_id=f"session-{session_id}"
)
```
</TabItem>
</Tabs>
## Next Steps
- [**Entities**](./entities) — Track people, places, and concepts
+58 -209
View File
@@ -4,13 +4,10 @@ sidebar_position: 7
# Entities
Entities are the people, organizations, places, and concepts that Hindsight automatically tracks across your memory bank.
Entities are the people, organizations, places, and concepts that Hindsight automatically extracts and tracks across your memory bank.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
:::info Automatic Feature
You don't need to do anything to use entities—Hindsight extracts them automatically when you call `retain`. However, understanding how entities work is important because they power key features in [recall](./recall) and [reflect](./reflect).
:::
## Why Entities Matter
@@ -21,243 +18,95 @@ Entities improve recall quality in two ways:
2. **Observations** — Hindsight synthesizes high-level summaries about each entity from multiple facts. Including entity observations in recall provides richer context.
:::tip Include Entities in Recall
Use `include_entities=True` in your recall calls to get entity observations alongside fact results. See [Recall](./recall) for details.
:::
## What Gets Extracted?
## What Are Entities?
When you retain information, the LLM extracts named entities from each fact:
When you retain information, Hindsight automatically identifies and tracks entities:
- **People** — Names like "Alice", "Dr. Smith", "CEO John"
- **Organizations** — Companies, teams, institutions
- **Places** — Cities, countries, specific locations
- **Products/Objects** — Software, tools, significant items
- **Concepts** — Abstract themes like "career growth", "friendship"
<Tabs>
<TabItem value="python" label="Python">
**Example:**
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.retain(
bank_id="my-bank",
content="Alice works at Google in Mountain View. She specializes in TensorFlow."
)
```
Content: "Alice works at Google in Mountain View. She specializes in TensorFlow."
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice works at Google in Mountain View. She specializes in TensorFlow.');
Entities extracted:
- Alice (person)
- Google (organization)
- Mountain View (location)
- TensorFlow (product)
```
</TabItem>
</Tabs>
**Entities extracted:**
- **Alice** (person)
- **Google** (organization)
- **Mountain View** (location)
- **TensorFlow** (product)
## Entity Resolution
Multiple mentions are unified into a single entity:
When the same entity is mentioned multiple times (possibly with different names), Hindsight resolves them to a single canonical entity using a scoring algorithm:
- "Alice" + "Alice Chen" + "Alice C." → one person
- "Bob" + "Robert Chen" → one person (nickname)
- Context-aware: "Apple (company)" vs "apple (fruit)"
### Resolution Factors
## List Entities
1. **Name similarity (50%)** — How closely the text matches existing entity names. Handles variations like "Alice" vs "Alice Chen" or partial matches.
Get all entities tracked in a memory bank:
2. **Co-occurrence (30%)** — Entities that frequently appear together are more likely to be the same. If "Alice" always appears with "Google" and "TensorFlow", a new mention of "Alice" near those entities scores higher for matching.
<Tabs>
<TabItem value="python" label="Python">
3. **Temporal proximity (20%)** — Recent mentions are weighted more heavily. If an entity was seen in the last 7 days, new similar mentions are more likely to match.
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
### Resolution Threshold
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
A match requires a combined score above **0.6** (60%). Below this threshold, Hindsight creates a new entity rather than risk merging distinct entities.
# List all entities
response = api.list_entities(bank_id="my-bank")
for entity in response.items:
print(f"{entity.canonical_name}: {entity.mention_count} mentions")
# List with pagination
response = api.list_entities(
bank_id="my-bank",
limit=50,
offset=0
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
import { sdk, createClient, createConfig } from '@vectorize-io/hindsight-client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all entities
const response = await sdk.listEntities({
client: apiClient,
path: { bank_id: 'my-bank' }
});
for (const entity of response.data.items) {
console.log(`${entity.canonical_name}: ${entity.mention_count} mentions`);
}
// List with pagination
const paginated = await sdk.listEntities({
client: apiClient,
path: { bank_id: 'my-bank' },
query: { limit: 50, offset: 0 }
});
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# List all entities
hindsight entities list my-bank
# With limit
hindsight entities list my-bank --limit 50
```
</TabItem>
</Tabs>
## Get Entity Details
Retrieve detailed information about a specific entity:
<Tabs>
<TabItem value="python" label="Python">
```python
# Get entity details with observations
entity = api.get_entity(
bank_id="my-bank",
entity_id="entity-uuid"
)
print(f"Entity: {entity.canonical_name}")
print(f"First seen: {entity.first_seen}")
print(f"Mentions: {entity.mention_count}")
# Observations (synthesized summaries)
for obs in entity.observations:
print(f" - {obs.text}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Get entity details
const entity = await sdk.getEntity({
client: apiClient,
path: { bank_id: 'my-bank', entity_id: 'entity-uuid' }
});
console.log(`Entity: ${entity.data.canonical_name}`);
console.log(`First seen: ${entity.data.first_seen}`);
console.log(`Mentions: ${entity.data.mention_count}`);
// Observations
for (const obs of entity.data.observations) {
console.log(` - ${obs.text}`);
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Get entity details
hindsight entities get my-bank entity-uuid
```
</TabItem>
</Tabs>
This means:
- Exact name matches with recent co-occurring entities → strong match
- Partial name matches without context → likely creates new entity
- Same name in completely different contexts → may create separate entities
## Entity Observations
Observations are high-level summaries automatically synthesized from multiple facts:
Observations are **derived state**high-level summaries that Hindsight automatically synthesizes from the facts associated with an entity. They provide a condensed view of what the system knows about important entities.
**Facts about Alice:**
**Example:**
Facts about Alice:
- "Alice works at Google"
- "Alice is a software engineer"
- "Alice specializes in ML"
- "Alice joined Google in 2020"
- "Alice leads the search team"
**Observation created:**
- "Alice is a software engineer at Google specializing in ML"
Observation created:
- "Alice is a software engineer at Google who joined in 2020, specializes in ML, and leads the search team"
Observations are generated in the background after retaining information.
### How Observations Work
## Regenerate Observations
Observations are **not generated for every entity**. When you retain new documents:
Force regeneration of entity observations:
1. **Top entities selected** — Hindsight identifies the top 5 most-mentioned entities in the batch
2. **Threshold check** — Only entities with at least 5 facts get observations
3. **Regeneration** — Observations are regenerated using the entity's most recent 50 facts
4. **Old observations replaced** — Previous observations are deleted and new ones created
<Tabs>
<TabItem value="python" label="Python">
This means:
- Frequently mentioned entities get observations; rarely mentioned ones don't
- Observations stay up-to-date as new information is retained
- The system prioritizes entities that matter most to your memory bank
```python
# Regenerate observations for an entity
api.regenerate_entity_observations(
bank_id="my-bank",
entity_id="entity-uuid"
)
```
### Observations vs Opinions
</TabItem>
<TabItem value="node" label="Node.js">
Observations are **objective summaries**—they synthesize facts without any bias or perspective. This is different from [opinions](./opinions), which are influenced by the memory bank's disposition.
```typescript
// Regenerate observations
await sdk.regenerateEntityObservations({
client: apiClient,
path: { bank_id: 'my-bank', entity_id: 'entity-uuid' }
});
```
| | Observations | Opinions |
|---|---|---|
| **Purpose** | Summarize what's known about an entity | Express the bank's perspective on a topic |
| **Disposition influence** | No | Yes |
| **Scope** | Per-entity | Any topic |
| **Generation** | Automatic (top entities) | On-demand via reflect |
</TabItem>
</Tabs>
### Using Observations
## Entity Response Format
```json
{
"id": "entity-uuid",
"canonical_name": "Alice Chen",
"first_seen": "2024-01-15T10:30:00Z",
"last_seen": "2024-03-20T14:22:00Z",
"mention_count": 47,
"observations": [
{
"text": "Alice is a software engineer at Google specializing in ML",
"mentioned_at": "2024-03-20T15:00:00Z"
}
]
}
```
Observations are included in recall results when you set `include_entities=True`. They provide quick context about key entities without retrieving all underlying facts.
## Next Steps
- [**Memory Banks**](./memory-banks) — Configure bank disposition
- [**Documents**](./documents) — Track document sources
- [**Operations**](./operations) — Monitor background tasks
- [**Recall**](./recall) — Use entities in memory retrieval
- [**Reflect**](./reflect) — Get entity-aware responses
+32 -209
View File
@@ -2,17 +2,26 @@
sidebar_position: 6
---
# Memory Bank
# Memory Banks
Configure memory bank disposition, background, and behavior.
Memory banks have characteristics:
- Banks are completely isolated from each other.
- You don't need to pre-create it, Hindsight will create it for you with default settings.
- Banks have a profile that influences how they form opinions from memories. (optional)
Memory banks are isolated containers that store all memory-related data for a specific context or use case.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## What is a Memory Bank?
A memory bank is a complete, isolated storage unit containing:
- **Memories** — Facts and information retained from conversations
- **Documents** — Files and content indexed for retrieval
- **Entities** — People, places, concepts extracted from memories
- **Relationships** — Connections between entities in the knowledge graph
Banks are completely isolated from each other — memories stored in one bank are not visible to another.
You don't need to pre-create a bank. Hindsight will automatically create it with default settings when you first use it.
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::
@@ -32,9 +41,9 @@ client.create_bank(
name="Research Assistant",
background="I am a research assistant specializing in machine learning",
disposition={
"skepticism": 4, # Questions claims, wants evidence
"literalism": 3, # Balanced interpretation
"empathy": 3 # Balanced emotional consideration
"skepticism": 4,
"literalism": 3,
"empathy": 3
}
)
```
@@ -75,51 +84,17 @@ hindsight bank disposition my-bank \
</TabItem>
</Tabs>
## Disposition Traits
## Background and Disposition
Each trait is scored 1 to 5:
Background and disposition are optional settings that influence how the bank forms opinions during [reflect](./reflect) operations.
| Trait | Low (1) | High (5) |
|-------|---------|----------|
| **Skepticism** | Trusting, accepts information at face value | Skeptical, questions and doubts claims |
| **Literalism** | Flexible interpretation, reads between the lines | Literal interpretation, takes things exactly as stated |
| **Empathy** | Detached, focuses on facts and logic | Empathetic, considers emotional context |
:::info
Background and disposition only affect the `reflect` operation (opinion formation). They do not impact `retain`, `recall`, or other memory operations.
:::
### How Traits Affect Behavior
### Background
**Skepticism** influences how the bank evaluates claims:
```python
# High skepticism (5)
"What's the source for this? Have these results been replicated?"
# Low skepticism (1)
"That sounds reasonable, let's proceed with that assumption."
```
**Literalism** affects interpretation:
```python
# High literalism (5)
"The requirement says 'users' - that means all users, no exceptions."
# Low literalism (1)
"When they say 'users', they probably mean active users in this context."
```
**Empathy** shapes how emotional context is considered:
```python
# High empathy (5)
"I understand this is frustrating. Let's find a solution that works for you."
# Low empathy (1)
"Here are the facts: Option A has 20% better performance than Option B."
```
## Background
The background is a first-person narrative providing bank context:
The background is a first-person narrative providing context for opinion formation:
<Tabs>
<TabItem value="python" label="Python">
@@ -147,164 +122,12 @@ await client.createBank('financial-advisor', {
</TabItem>
</Tabs>
Background influences:
- How questions are interpreted
- Perspective in responses
- Opinion formation context
### Disposition Traits
## Getting Bank Profile
Disposition traits influence how opinions are formed during reflection. Each trait is scored 1 to 5:
<Tabs>
<TabItem value="python" label="Python">
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
profile = api.get_bank_profile("my-bank")
print(f"Name: {profile.name}")
print(f"Background: {profile.background}")
print(f"Disposition: {profile.disposition}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const profile = await client.getBankProfile('my-bank');
console.log(`Name: ${profile.name}`);
console.log(`Background: ${profile.background}`);
console.log(`Disposition:`, profile.disposition);
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight bank profile my-bank
```
</TabItem>
</Tabs>
## Default Values
If not specified, banks use neutral defaults:
```python
{
"skepticism": 3,
"literalism": 3,
"empathy": 3,
"background": ""
}
```
## Disposition Templates
Common disposition configurations:
| Use Case | Skepticism | Literalism | Empathy |
|----------|------------|------------|---------|
| **Customer Support** | 2 | 2 | 5 |
| **Code Reviewer** | 4 | 5 | 2 |
| **Legal Analyst** | 5 | 5 | 2 |
| **Therapist/Coach** | 2 | 2 | 5 |
| **Research Assistant** | 4 | 3 | 3 |
| **Neutral (default)** | 3 | 3 | 3 |
<Tabs>
<TabItem value="python" label="Python">
```python
# Customer support bank
client.create_bank(
bank_id="support",
background="I am a friendly customer support agent",
disposition={
"skepticism": 2, # Trusting
"literalism": 2, # Flexible interpretation
"empathy": 5 # Very empathetic
}
)
# Code reviewer bank
client.create_bank(
bank_id="reviewer",
background="I am a thorough code reviewer focused on quality",
disposition={
"skepticism": 4, # Questions assumptions
"literalism": 5, # Exact interpretation
"empathy": 2 # Direct, fact-focused
}
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Customer support bank
await client.createBank('support', {
background: 'I am a friendly customer support agent',
disposition: {
skepticism: 2,
literalism: 2,
empathy: 5
}
});
// Code reviewer bank
await client.createBank('reviewer', {
background: 'I am a thorough code reviewer focused on quality',
disposition: {
skepticism: 4,
literalism: 5,
empathy: 2
}
});
```
</TabItem>
</Tabs>
## Bank Isolation
Each bank has:
- **Separate memories** — banks don't share memories
- **Own disposition** — traits are per-bank
- **Independent opinions** — formed from their own experiences
<Tabs>
<TabItem value="python" label="Python">
```python
# Store to bank A
client.retain(bank_id="bank-a", content="Python is great")
# Bank B doesn't see it
results = client.recall(bank_id="bank-b", query="Python")
# Returns empty
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Store to bank A
await client.retain('bank-a', 'Python is great');
// Bank B doesn't see it
const results = await client.recall('bank-b', 'Python');
// Returns empty
```
</TabItem>
</Tabs>
| Trait | Low (1) | High (5) |
|-------|---------|----------|
| **Skepticism** | Trusting, accepts information at face value | Skeptical, questions and doubts claims |
| **Literalism** | Flexible interpretation, reads between the lines | Literal interpretation, takes things exactly as stated |
| **Empathy** | Detached, focuses on facts and logic | Empathetic, considers emotional context |
+15 -274
View File
@@ -4,290 +4,31 @@ sidebar_position: 9
# Operations
Monitor and manage long-running background tasks in Hindsight.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Background tasks that Hindsight executes asynchronously.
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
:::
## What Are Operations?
## How Operations Work
When you call `retain_batch` with `async=True`, Hindsight processes the content in the background and returns immediately with an operation ID. Operations let you track and manage these async retain tasks.
Hindsight processes several types of tasks in the background to maintain memory quality and consistency. These operations run automatically—you don't need to trigger them manually.
By default, async operations are executed in-process within the API service. This is managed automatically — you don't need to configure anything.
By default, all background operations are executed in-process within the API service.
:::tip Scaling with Streaming
For high-throughput workloads, you can extend the task backend to use a streaming platform like Kafka. This enables scale-out processing across multiple workers and handles backpressure on the API.
:::note Kafka Integration
Support for external streaming platforms like Kafka for scale-out processing is planned but **not available out of the box** in the current release.
:::
## Async Batch Retain
For large content batches, use async mode to avoid timeouts:
<Tabs>
<TabItem value="python" label="Python">
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.retain_batch(
bank_id="my-bank",
items=[
{"content": doc1_text},
{"content": doc2_text},
],
retain_async=True
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retainBatch('my-bank', [
{ content: doc1Text },
{ content: doc2Text },
], { async: true });
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight retain my-bank --files docs/*.md --async
```
</TabItem>
</Tabs>
## List Operations
View all operations for a memory bank:
<Tabs>
<TabItem value="python" label="Python">
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all operations
response = api.list_operations(bank_id="my-bank")
for op in response.items:
print(f"{op.id}: {op.task_type} - {op.status}")
print(f" Items: {op.items_count}")
if op.error_message:
print(f" Error: {op.error_message}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
import { sdk, createClient, createConfig } from '@vectorize-io/hindsight-client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all operations
const response = await sdk.listOperations({
client: apiClient,
path: { bank_id: 'my-bank' }
});
for (const op of response.data.items) {
console.log(`${op.id}: ${op.task_type} - ${op.status}`);
console.log(` Items: ${op.items_count}`);
if (op.error_message) {
console.log(` Error: ${op.error_message}`);
}
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# List all operations
hindsight operations list my-bank
# Filter by status
hindsight operations list my-bank --status running
# Watch all running operations
hindsight operations watch my-bank --all
```
</TabItem>
</Tabs>
## Cancel Operations
Stop a running or pending operation:
<Tabs>
<TabItem value="python" label="Python">
```python
# Cancel operation
api.cancel_operation(
bank_id="my-bank",
operation_id="op-abc123"
)
# Cancel all pending operations
response = api.list_operations(bank_id="my-bank")
for op in response.items:
if op.status == "pending":
api.cancel_operation(bank_id="my-bank", operation_id=op.id)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Cancel operation
await sdk.cancelOperation({
client: apiClient,
path: { bank_id: 'my-bank', operation_id: 'op-abc123' }
});
// Cancel all pending
const ops = await sdk.listOperations({
client: apiClient,
path: { bank_id: 'my-bank' }
});
for (const op of ops.data.items) {
if (op.status === 'pending') {
await sdk.cancelOperation({
client: apiClient,
path: { bank_id: 'my-bank', operation_id: op.id }
});
}
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Cancel operation
hindsight operations cancel my-bank op-abc123
# Cancel all pending
hindsight operations cancel my-bank --all-pending
```
</TabItem>
</Tabs>
## Operation States
| State | Description |
|-------|-------------|
| **pending** | Queued, waiting to start |
| **running** | Currently processing |
| **completed** | Successfully finished |
| **failed** | Encountered an error |
| **cancelled** | Stopped by user |
## Monitoring Strategies
### Polling
<Tabs>
<TabItem value="python" label="Python">
```python
import time
def wait_for_operations(api, bank_id, poll_interval=5):
"""Wait for all pending/running operations to complete."""
while True:
response = api.list_operations(bank_id=bank_id)
pending_or_running = [
op for op in response.items
if op.status in ['pending', 'running']
]
if not pending_or_running:
print("All operations completed!")
break
for op in pending_or_running:
print(f" {op.id}: {op.status} ({op.items_count} items)")
time.sleep(poll_interval)
# Use it
wait_for_operations(api, "my-bank")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
async function waitForOperations(apiClient: any, bankId: string, pollInterval = 5000) {
while (true) {
const response = await sdk.listOperations({
client: apiClient,
path: { bank_id: bankId }
});
const pendingOrRunning = response.data.items.filter(
(op: any) => ['pending', 'running'].includes(op.status)
);
if (pendingOrRunning.length === 0) {
console.log('All operations completed!');
break;
}
for (const op of pendingOrRunning) {
console.log(` ${op.id}: ${op.status} (${op.items_count} items)`);
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
}
// Use it
await waitForOperations(apiClient, 'my-bank');
```
</TabItem>
</Tabs>
## Performance Tips
**Use async for large batches:**
- Sync: < 100 items or < 100KB
- Async: > 100 items or > 100KB
**Monitor progress:**
- Check `items_count` field
- Poll every 5-10 seconds
**Handle failures:**
- Check `error_message` field for details
- Retry with exponential backoff
- Break large batches into smaller chunks
## Operation Types
| Operation | Trigger | Description |
|-----------|---------|-------------|
| **batch_retain** | `retain_batch` with `async=True` | Processes large content batches in the background |
| **form_opinion** | After each `reflect` call | Extracts and stores new opinions formed during reflection |
| **reinforce_opinion** | After `retain` | Updates opinion confidence based on new supporting evidence |
| **access_count_update** | After `recall` | Tracks which memories are accessed for relevance scoring |
| **regenerate_observations** | Bank profile update | Regenerates entity observations when disposition changes |
## Next Steps
@@ -31,10 +31,10 @@ API available at http://localhost:8888
export OPENAI_API_KEY=sk-xxx
docker run -it -p 8888:8888 -p 9999:9999 \
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
ghcr.io/vectorize-io/hindsight:latest
```
- **API**: http://localhost:8888
@@ -100,7 +100,7 @@ await client.reflect('my-bank', 'Tell me about Alice');
<TabItem value="cli" label="CLI">
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```
```bash
+40 -96
View File
@@ -2,18 +2,22 @@
sidebar_position: 2
---
# Search Facts
# Recall Memories
Retrieve memories using multi-strategy search.
Retrieve memories using multi-strategy recall.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::info How Recall Works
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
:::
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::
## Basic Search
## Basic Recall
<Tabs>
<TabItem value="python" label="Python">
@@ -23,7 +27,9 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.recall(bank_id="my-bank", query="What does Alice do?")
response = client.recall(bank_id="my-bank", query="What does Alice do?")
for r in response.results:
print(f"{r.text} (score: {r.weight:.2f})")
```
</TabItem>
@@ -34,20 +40,23 @@ import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.recall('my-bank', 'What does Alice do?');
const response = await client.recall('my-bank', 'What does Alice do?');
for (const r of response.results) {
console.log(`${r.text} (score: ${r.weight})`);
}
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-bank "What does Alice do?"
hindsight recall my-bank "What does Alice do?"
```
</TabItem>
</Tabs>
## Search Parameters
## Recall Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
@@ -55,43 +64,15 @@ hindsight memory search my-bank "What does Alice do?"
| `types` | list | all | Filter: `world`, `experience`, `opinion` |
| `budget` | string | "mid" | Budget level: "low", "mid", "high" |
| `max_tokens` | int | 4096 | Token budget for results |
| `trace` | bool | false | Enable trace output for debugging |
| `include_entities` | bool | false | Include entity observations |
| `max_entity_tokens` | int | 500 | Token budget for entity observations |
<Tabs>
<TabItem value="python" label="Python">
```python
results = client.recall(
bank_id="my-bank",
query="What does Alice do?",
types=["world", "experience"],
budget="high",
max_tokens=8000
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const results = await client.recall('my-bank', 'What does Alice do?', {
budget: 'high',
maxTokens: 8000
});
```
</TabItem>
</Tabs>
## Full-Featured Search
For more control, use the full-featured recall method:
<Tabs>
<TabItem value="python" label="Python">
```python
# Full response with trace info
response = client.recall_memories(
response = client.recall(
bank_id="my-bank",
query="What does Alice do?",
types=["world", "experience"],
@@ -103,22 +84,20 @@ response = client.recall_memories(
)
# Access results
for r in response["results"]:
print(f"{r['text']} (score: {r['weight']:.2f})")
for r in response.results:
print(f"{r.text} (score: {r.weight:.2f})")
# Access entity observations (if include_entities=True)
if "entities" in response:
for entity in response["entities"]:
print(f"Entity: {entity['name']}")
if response.entities:
for entity in response.entities:
print(f"Entity: {entity.name}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Full response with trace info
const response = await client.recallMemories('my-bank', {
query: 'What does Alice do?',
const response = await client.recall('my-bank', 'What does Alice do?', {
types: ['world', 'experience'],
budget: 'high',
maxTokens: 8000,
@@ -134,44 +113,9 @@ for (const r of response.results) {
</TabItem>
</Tabs>
## Temporal Queries
Hindsight automatically detects time expressions and activates temporal search:
<Tabs>
<TabItem value="python" label="Python">
```python
# These queries activate temporal-graph retrieval
results = client.recall(bank_id="my-bank", query="What did Alice do last spring?")
results = client.recall(bank_id="my-bank", query="What happened in June?")
results = client.recall(bank_id="my-bank", query="Events from last year")
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-bank "What did Alice do last spring?"
hindsight memory search my-bank "What happened between March and May?"
```
</TabItem>
</Tabs>
Supported temporal expressions:
| Expression | Parsed As |
|------------|-----------|
| "last spring" | March 1 - May 31 (previous year) |
| "in June" | June 1-30 (current/nearest year) |
| "last year" | Jan 1 - Dec 31 (previous year) |
| "last week" | 7 days ago - today |
| "between March and May" | March 1 - May 31 |
## Filter by Fact Type
Search specific memory networks:
Recall specific memory types:
<Tabs>
<TabItem value="python" label="Python">
@@ -210,20 +154,23 @@ facts = client.recall(
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-bank "Python" --fact-type opinion
hindsight memory search my-bank "Alice" --fact-type world,experience
hindsight recall my-bank "Python" --fact-type opinion
hindsight recall my-bank "Alice" --fact-type world,experience
```
</TabItem>
</Tabs>
:::info How Recall Works
Learn about the four search strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
:::warning About Opinions
Opinions are beliefs formed during [reflect](/developer/api/reflect) operations. Unlike world facts and experience, opinions are subjective interpretations and may not represent objective truth. Depending on your use case:
- **Exclude opinions** (`types=["world", "experience"]`) when you need factual, verifiable information
- **Include opinions** when you want the agent's perspective or formed beliefs
- **Use opinions alone** (`types=["opinion"]`) only when specifically asking about the agent's views
:::
## Token Budget Management
Hindsight is built for AI agents, not humans. Traditional search systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.
Hindsight is built for AI agents, not humans. Traditional retrieval systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.
The `max_tokens` parameter lets you control how much of your agent's context budget to spend on memories:
@@ -237,9 +184,9 @@ results = client.recall(bank_id="my-bank", query="Alice's email", max_tokens=500
This design means you never have to guess whether 10 results or 50 results will fit your context. Just specify the token budget and Hindsight returns as many relevant memories as will fit.
### Additional Context: Chunks and Entity Observations
## Include Related Context
For the most relevant memories, you can optionally retrieve additional context—each with its own token budget:
Beyond the core memory results, you can optionally retrieve additional context—each with its own token budget:
| Option | Parameter | Description |
|--------|-----------|-------------|
@@ -247,19 +194,16 @@ For the most relevant memories, you can optionally retrieve additional context
| **Entity Observations** | `include_entities`, `max_entity_tokens` | Related observations about entities mentioned in results |
```python
response = client.recall_memories(
response = client.recall(
bank_id="my-bank",
query="What does Alice do?",
max_tokens=4096, # Budget for memories
include_chunks=True,
max_chunk_tokens=2000, # Budget for raw chunks
include_entities=True,
max_entity_tokens=1000 # Budget for entity observations
)
# Access the additional context
chunks = response.get("chunks", {})
entities = response.get("entities", [])
entities = response.entities or []
```
This gives your agent richer context while maintaining precise control over total token consumption.
@@ -268,7 +212,7 @@ This gives your agent richer context while maintaining precise control over tota
The `budget` parameter controls graph traversal depth:
- **"low"**: Fast, shallow search — good for simple lookups
- **"low"**: Fast, shallow retrieval — good for simple lookups
- **"mid"**: Balanced — default for most queries
- **"high"**: Deep exploration — finds indirect connections
+47 -19
View File
@@ -6,9 +6,21 @@ sidebar_position: 3
Generate disposition-aware responses using retrieved memories.
When you call **reflect**, Hindsight performs a multi-step reasoning process:
1. **Recalls** relevant memories from the bank based on your query
2. **Applies** the bank's disposition traits to shape the reasoning style
3. **Generates** a contextual answer grounded in the retrieved facts
4. **Forms opinions** in the background based on the reasoning (available in subsequent calls)
The response includes the generated answer along with the facts that were used, providing full transparency into how the answer was derived.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::info How Reflect Works
Learn about disposition-driven reasoning and opinion formation in the [Reflect Architecture](/developer/reflect) guide.
:::
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::
@@ -80,34 +92,50 @@ const response = await client.reflect('my-bank', 'What do you think about remote
</TabItem>
</Tabs>
:::info How Reflect Works
Learn about disposition-driven reasoning and opinion formation in the [Reflect Architecture](/developer/reflect) guide.
:::
## The Role of Context
## Opinion Formation
The `context` parameter steers how the reflection is performed without impacting the memory recall. It provides situational information that helps shape the reasoning and response.
Reflect can form new opinions based on evidence:
**How context is used:**
- **Shapes reasoning**: Helps understand the situation when formulating an answer
- **Disambiguates intent**: Clarifies what aspect of the query matters most
- **Does not affect recall**: The same memories are retrieved regardless of context
<Tabs>
<TabItem value="python" label="Python">
```python
# Context is passed to the LLM to help it understand the situation
response = client.reflect(
bank_id="my-bank",
query="What do you think about Python vs JavaScript for data science?"
query="What do you think about the proposal?",
context="We're in a budget review meeting discussing Q4 spending"
)
```
# Response might include:
# answer: "Based on what I know about data science workflows..."
# new_opinions: [
# {"text": "Python is better for data science", "id": "..."}
# ]
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Context helps the LLM understand the current situation
const response = await client.reflect('my-bank', 'What do you think about the proposal?', {
context: "We're in a budget review meeting discussing Q4 spending"
});
```
</TabItem>
</Tabs>
New opinions are automatically stored and influence future responses.
## Opinion Formation
When reflect reasons about a question, it may form new **opinions** based on the evidence in the memory bank. These opinions are created in the background and become available in subsequent `reflect` and `recall` calls.
**Why opinions matter:**
- **Consistent thinking**: Opinions ensure the memory bank maintains a coherent perspective over time
- **Evolving viewpoints**: As more information is retained, opinions can be refined or updated
- **Grounded reasoning**: Opinions are always derived from factual evidence in the memory bank
Opinions are stored as a special memory type and are automatically retrieved when relevant to future queries. This creates a natural evolution of the bank's perspective, similar to how humans form and refine their views based on accumulated experience.
## Disposition Influence
@@ -165,7 +193,7 @@ const response = await client.reflect('cautious-advisor', 'Should I invest in cr
## Using Sources
The `facts_used` field shows which memories informed the response:
The `based_on` field shows which memories informed the response:
<Tabs>
<TabItem value="python" label="Python">
@@ -173,10 +201,10 @@ The `facts_used` field shows which memories informed the response:
```python
response = client.reflect(bank_id="my-bank", query="Tell me about Alice")
print("Response:", response["answer"])
print("Response:", response.text)
print("\nBased on:")
for fact in response.get("facts_used", []):
print(f" - {fact['text']} (relevance: {fact['weight']:.2f})")
for fact in response.based_on or []:
print(f" - [{fact.type}] {fact.text}")
```
</TabItem>
@@ -185,10 +213,10 @@ for fact in response.get("facts_used", []):
```typescript
const response = await client.reflect('my-bank', 'Tell me about Alice');
console.log('Response:', response.answer);
console.log('Response:', response.text);
console.log('\nBased on:');
for (const fact of response.facts_used || []) {
console.log(` - ${fact.text} (relevance: ${fact.weight.toFixed(2)})`);
for (const fact of response.based_on || []) {
console.log(` - [${fact.type}] ${fact.text}`);
}
```
+26 -22
View File
@@ -4,11 +4,17 @@ sidebar_position: 2
# Ingest Data
Store memories, conversations, and documents into Hindsight.
Store documents, conversations, and raw content into Hindsight to automatically extract and create memories.
When you **retain** content, Hindsight doesn't just store the raw text—it intelligently analyzes the content to extract meaningful facts, identify entities, and build a connected knowledge graph. This process transforms unstructured information into structured, queryable memories.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::info How Retain Works
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
:::
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::
@@ -50,9 +56,18 @@ hindsight memory put my-bank "Alice works at Google as a software engineer"
</TabItem>
</Tabs>
## The Importance of Context
The `context` parameter is crucial for guiding how Hindsight extracts memories from your content. Think of it as providing a lens through which the system interprets the information.
**Why context matters:**
- **Steers memory extraction**: Context tells the memory bank what type of information to focus on and how to interpret ambiguous content
- **Improves relevance**: Memories extracted with proper context are more accurately categorized and easier to retrieve
- **Disambiguates meaning**: The same sentence can have different implications depending on context (e.g., "the project was terminated" means different things in a career vs. product context)
## Store with Context and Date
Add context and event dates for better retrieval:
Always provide context and event dates for optimal memory extraction:
<Tabs>
<TabItem value="python" label="Python">
@@ -88,11 +103,11 @@ hindsight memory put my-bank "Alice got promoted" \
</TabItem>
</Tabs>
The `timestamp` enables temporal queries like "What happened last spring?"
The `timestamp` defaults to the current time if not specified. Providing explicit timestamps enables temporal queries like "What happened last spring?"
## Batch Ingestion
Store multiple memories in a single request:
Store multiple items in a single request. **Batch ingestion is the recommended approach** as it significantly improves performance by reducing network overhead and allowing Hindsight to optimize the memory extraction process across related content.
<Tabs>
<TabItem value="python" label="Python">
@@ -144,51 +159,40 @@ hindsight memory put-files my-bank report.pdf --document-id "q4-report"
</TabItem>
</Tabs>
:::info How Retain Works
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
:::
## Async Ingestion
For large batches, use async ingestion:
For large batches, use async ingestion to avoid blocking:
<Tabs>
<TabItem value="python" label="Python">
```python
# Start async ingestion
# Start async ingestion (returns immediately)
result = client.retain_batch(
bank_id="my-bank",
items=[...large batch...],
document_id="large-doc",
async_=True
retain_async=True
)
# Result contains operation_id for tracking
print(result["operation_id"])
# Check if it was processed asynchronously
print(result.var_async) # True
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Start async ingestion
// Start async ingestion (returns immediately)
const result = await client.retainBatch('my-bank', largeItems, {
documentId: 'large-doc',
async: true
});
console.log(result.operation_id);
console.log(result.async); // true
```
</TabItem>
</Tabs>
## Best Practices
| Do | Don't |
|----|-------|
| Include context for better retrieval | Store raw unstructured dumps |
| Use document_id to group related content | Mix unrelated content in one batch |
| Add timestamp for temporal queries | Omit dates if time matters |
| Store conversations as they happen | Wait to batch everything |
@@ -1,153 +0,0 @@
---
sidebar_position: 4
---
# Think vs Search
When to use `search` vs `think`.
## Quick Comparison
| | Search | Think |
|---|--------|-------|
| **Returns** | Raw memory results | Generated response |
| **Use case** | Retrieval, lookup | Q&A, reasoning |
| **LLM calls** | 0 (retrieval only) | 1+ (generation) |
| **Speed** | Fast (~100-200ms) | Slower (~500-2000ms) |
| **Opinions** | Returns existing | Can form new ones |
| **Disposition** | Not applied | Applied to response |
## When to Use Search
**Use Search when you need:**
- Raw facts for your own processing
- Fast retrieval without generation
- To populate context for another LLM
- To check what's in memory
- Debugging retrieval quality
```python
# Get raw facts to inject into your own prompt
results = client.search(agent_id="my-agent", query="Alice's preferences")
context = "\n".join([r["text"] for r in results])
# Use context in your own LLM call
```
**Examples:**
```python
# Lookup — just get the facts
results = client.search(agent_id="my-agent", query="Alice's email address")
# Context building — feed into another system
results = client.search(agent_id="my-agent", query="Recent project discussions")
context = format_for_prompt(results)
# Verification — check what's stored
results = client.search(agent_id="my-agent", query="What do I know about Bob?")
```
## When to Use Think
**Use Think when you need:**
- A natural language response
- Disposition-aware answers
- Opinion formation
- Reasoning over multiple facts
- Source attribution
```python
# Get a complete answer with disposition
answer = client.think(agent_id="my-agent", query="What should I recommend to Alice?")
print(answer["text"]) # Natural language response
print(answer["based_on"]) # Sources used
```
**Examples:**
```python
# Q&A — need a response, not just facts
answer = client.think(agent_id="my-agent", query="What does Alice do for work?")
# Reasoning — synthesize multiple facts
answer = client.think(agent_id="my-agent", query="How are Alice and Bob connected?")
# Opinion — agent forms a view
answer = client.think(agent_id="my-agent", query="What do you think about Python?")
# Recommendation — disposition-influenced
answer = client.think(agent_id="my-agent", query="What book should I read next?")
```
## Performance Comparison
```mermaid
graph LR
subgraph Search
S1[Query] --> S2[4-way Retrieval]
S2 --> S3[RRF + Rerank]
S3 --> S4[Results]
end
subgraph Think
T1[Query] --> T2[4-way Retrieval]
T2 --> T3[RRF + Rerank]
T3 --> T4[Load Disposition]
T4 --> T5[LLM Generation]
T5 --> T6[Store Opinions]
T6 --> T7[Response]
end
```
| Operation | Search | Think |
|-----------|--------|-------|
| Retrieval | ~100ms | ~100ms |
| Reranking | ~35ms | ~35ms |
| LLM Generation | — | ~500-1500ms |
| Opinion Storage | — | ~50ms |
| **Total** | **~135ms** | **~700-1700ms** |
## Hybrid Pattern
Use Search for context, Think for final response:
```python
# First: fast search to check relevance
results = client.search(agent_id="my-agent", query="Alice project status")
if len(results) > 0:
# Only call Think if we have relevant memories
answer = client.think(agent_id="my-agent", query="Summarize Alice's project status")
else:
answer = {"text": "I don't have information about Alice's projects."}
```
## Decision Flowchart
```mermaid
graph TD
A[Need memory access] --> B{Need natural language response?}
B -->|No| C[Use Search]
B -->|Yes| D{Need disposition/opinions?}
D -->|No| E{Building context for another LLM?}
E -->|Yes| C
E -->|No| F[Use Think]
D -->|Yes| F
```
## Cost Considerations
| Factor | Search | Think |
|--------|--------|-------|
| API calls | 1 | 1 |
| LLM tokens | 0 | 500-2000 |
| Latency | Low | Medium |
| Cost | Low | Higher (LLM usage) |
If you're making many requests or building a high-throughput system, consider:
- Use Search for bulk operations
- Use Think for user-facing responses
- Cache Think responses when appropriate
@@ -24,7 +24,7 @@ Configure the LLM provider used for fact extraction, entity resolution, and reas
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider: `groq`, `openai`, `ollama` | `groq` | Yes |
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider: `groq`, `openai`, `gemini`, `ollama` | `groq` | Yes |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - | Yes (except ollama) |
| `HINDSIGHT_API_LLM_MODEL` | Model name | Provider-specific | No |
| `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default | No |
@@ -47,6 +47,14 @@ export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o
```
**Gemini**
```bash
export HINDSIGHT_API_LLM_PROVIDER=gemini
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
```
**Ollama (Local, No API Key)**
```bash
+15 -12
View File
@@ -37,11 +37,12 @@ See [Models](./models) for detailed comparison and configuration.
Run everything in one container with embedded PostgreSQL:
```bash
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
ghcr.io/vectorize-io/hindsight
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API Server**: http://localhost:8888
@@ -54,27 +55,29 @@ docker run -p 8888:8888 -p 9999:9999 \
**Best for**: Production deployments, auto-scaling, cloud environments
```bash
# Add Hindsight Helm repository
helm repo add hindsight https://vectorize-io.github.io/hindsight
helm repo update
# Install with built-in PostgreSQL
helm install hindsight hindsight/hindsight \
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set api.llm.provider=groq \
--set api.llm.apiKey=gsk_xxxxxxxxxxxx \
--set postgresql.enabled=true
# Or use external PostgreSQL
helm install hindsight hindsight/hindsight \
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set api.llm.provider=groq \
--set api.llm.apiKey=gsk_xxxxxxxxxxxx \
--set postgresql.enabled=false \
--set api.database.url=postgresql://user:[email protected]:5432/hindsight
# Install a specific version
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight --version 0.1.3
# Upgrade to latest
helm upgrade hindsight oci://ghcr.io/vectorize-io/charts/hindsight
```
**Requirements**:
- Kubernetes cluster (GKE, EKS, AKS, or self-hosted)
- Helm 3+
- Helm 3.8+
See the [Helm chart documentation](https://github.com/vectorize-io/hindsight/tree/main/helm) for advanced configuration.
+45 -31
View File
@@ -8,37 +8,50 @@ Hindsight includes a built-in [Model Context Protocol (MCP)](https://modelcontex
## Access
The MCP server is **enabled by default** and mounted at `/mcp` on the API server:
The MCP server is **enabled by default** and mounted at `/mcp` on the API server. Each memory bank has its own MCP endpoint:
```
http://localhost:8888/mcp
http://localhost:8888/mcp/{bank_id}/
```
To disable it, set the environment variable:
For example, to connect to the memory bank `alice`:
```
http://localhost:8888/mcp/alice/
```
To disable the MCP server, set the environment variable:
```bash
export HINDSIGHT_API_MCP_ENABLED=false
```
## Per-Bank Endpoints
Unlike traditional MCP servers where tools require explicit identifiers, Hindsight uses **per-bank endpoints**. The `bank_id` is part of the URL path, so tools don't need to specify which bank to use—it's implicit from the connection.
This design:
- **Simplifies tool usage** — no need to pass `bank_id` with every call
- **Enforces isolation** — each MCP connection is scoped to a single bank
- **Enables multi-tenant setups** — connect different users to different endpoints
---
## Available Tools
### hindsight_put
### retain
Store information to a user's memory bank.
Store information to long-term memory.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `bank_id` | string | Yes | Unique identifier for the user (e.g., `user_12345`, `[email protected]`) |
| `content` | string | Yes | The fact or memory to store |
| `context` | string | Yes | Category for the memory (e.g., `personal_preferences`, `work_history`) |
| `explanation` | string | No | Why this memory is being stored |
| `context` | string | No | Category for the memory (default: `general`) |
**Example:**
```json
{
"name": "hindsight_put",
"name": "retain",
"arguments": {
"bank_id": "user_12345",
"content": "User prefers Python over JavaScript for backend development",
"context": "programming_preferences"
}
@@ -53,23 +66,20 @@ Store information to a user's memory bank.
---
### hindsight_search
### recall
Search a user's memory bank to provide personalized responses.
Search memories to provide personalized responses.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `bank_id` | string | Yes | Unique identifier for the user |
| `query` | string | Yes | Natural language search query |
| `max_tokens` | integer | No | Maximum tokens for results (default: 4096) |
| `explanation` | string | No | Why this search is being performed |
| `max_results` | integer | No | Maximum results to return (default: 10) |
**Example:**
```json
{
"name": "hindsight_search",
"name": "recall",
"arguments": {
"bank_id": "user_12345",
"query": "What are the user's programming language preferences?"
}
}
@@ -84,8 +94,7 @@ Search a user's memory bank to provide personalized responses.
"text": "User prefers Python over JavaScript for backend development",
"type": "world",
"context": "programming_preferences",
"event_date": null,
"document_id": null
"event_date": null
}
]
}
@@ -99,17 +108,22 @@ Search a user's memory bank to provide personalized responses.
---
## Per-User Isolation
Both tools require a `bank_id` that uniquely identifies the user. Memories are strictly isolated per bank — one user cannot access another user's memories.
**Best practices:**
- Use consistent identifiers (user ID, email, session ID)
- Don't share `bank_id` between different users
- Only call these tools when you can identify the specific user
---
## Integration with AI Assistants
The MCP server can be used with any MCP-compatible AI assistant. For Claude Desktop integration using the CLI, see [MCP Server (CLI)](/sdks/mcp).
The MCP server can be used with any MCP-compatible AI assistant.
### Claude Desktop Configuration
To connect Claude Desktop to a specific memory bank:
```json
{
"mcpServers": {
"hindsight-alice": {
"url": "http://localhost:8888/mcp/alice/"
}
}
}
```
Each user can have their own MCP server configuration pointing to their personal memory bank.
+11 -35
View File
@@ -8,45 +8,21 @@ curl http://localhost:8888/metrics
## Available Metrics
### Request Metrics
### Operation Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `hindsight_http_requests_total` | Counter | Total HTTP requests (labels: method, endpoint, status_code) |
| `hindsight_http_request_duration_seconds` | Histogram | Request latency (labels: method, endpoint) |
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.operation.duration` | Histogram | operation, bank_id, budget, max_tokens, success | Duration of operations in seconds |
| `hindsight.operation.total` | Counter | operation, bank_id, budget, max_tokens, success | Total number of operations executed |
### Memory Operations
The `operation` label values are: `retain`, `recall`, `reflect`.
| Metric | Type | Description |
|--------|------|-------------|
| `hindsight_retain_duration_seconds` | Histogram | Retain operation latency |
| `hindsight_retain_items_total` | Counter | Total items retained |
| `hindsight_recall_duration_seconds` | Histogram | Recall operation latency |
| `hindsight_recall_results_count` | Histogram | Number of results per recall |
| `hindsight_reflect_duration_seconds` | Histogram | Reflect operation latency |
### Token Metrics
### LLM Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `hindsight_llm_requests_total` | Counter | LLM API requests (labels: provider, model, status) |
| `hindsight_llm_request_duration_seconds` | Histogram | LLM request latency |
| `hindsight_llm_tokens_total` | Counter | Tokens consumed (labels: provider, token_type) |
### Database Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `hindsight_db_connections_active` | Gauge | Active database connections |
| `hindsight_db_connections_idle` | Gauge | Idle connections in pool |
| `hindsight_db_query_duration_seconds` | Histogram | Query latency (labels: query_type) |
### Memory Bank Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `hindsight_bank_memory_units_total` | Gauge | Total memories per bank |
| `hindsight_bank_entities_total` | Gauge | Total entities per bank |
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.tokens.input` | Counter | operation, bank_id, budget, max_tokens | Input tokens consumed |
| `hindsight.tokens.output` | Counter | operation, bank_id, budget, max_tokens | Output tokens generated |
## Prometheus Configuration
+31 -30
View File
@@ -7,7 +7,7 @@ Hindsight uses several machine learning models for different tasks.
| Model Type | Purpose | Default | Configurable |
|------------|---------|---------|--------------|
| **Embedding** | Vector representations for semantic search | `BAAI/bge-small-en-v1.5` | Yes |
| **Cross-Encoder** | Reranking search results | `ms-marco-MiniLM-L-6-v2` | Yes |
| **Cross-Encoder** | Reranking search results | `cross-encoder/ms-marco-MiniLM-L-6-v2` | Yes |
| **LLM** | Fact extraction, reasoning, generation | Provider-specific | Yes |
All local models (embedding, cross-encoder) are automatically downloaded from HuggingFace on first run.
@@ -28,12 +28,20 @@ Converts text into dense vector representations for semantic similarity search.
| `BAAI/bge-base-en-v1.5` | 768 | Higher accuracy, slower |
| `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | 384 | Multilingual (50+ languages) |
:::warning
All embedding models must produce 384-dimensional vectors to match the database schema.
:::
**Configuration:**
```bash
export HINDSIGHT_API_EMBEDDING_MODEL=BAAI/bge-base-en-v1.5
export HINDSIGHT_API_EMBEDDING_DEVICE=cuda # or mps for Apple Silicon
export HINDSIGHT_API_EMBEDDING_BATCH_SIZE=64
# Local provider (default)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# TEI provider (remote)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei
export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
```
---
@@ -48,16 +56,20 @@ Reranks initial search results to improve precision.
| Model | Use Case |
|-------|----------|
| `ms-marco-MiniLM-L-6-v2` | Default, fast |
| `ms-marco-MiniLM-L-12-v2` | Higher accuracy |
| `mmarco-mMiniLMv2-L12-H384-v1` | Multilingual |
| `cross-encoder/ms-marco-MiniLM-L-6-v2` | Default, fast |
| `cross-encoder/ms-marco-MiniLM-L-12-v2` | Higher accuracy |
| `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | Multilingual |
**Configuration:**
```bash
export HINDSIGHT_API_RERANK_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2
export HINDSIGHT_API_RERANK_TOP_K=50 # How many results to rerank
export HINDSIGHT_API_RERANK_ENABLED=true # Set to false to disable
# Local provider (default)
export HINDSIGHT_API_RERANKER_PROVIDER=local
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# TEI provider (remote)
export HINDSIGHT_API_RERANKER_PROVIDER=tei
export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
```
---
@@ -66,14 +78,14 @@ export HINDSIGHT_API_RERANK_ENABLED=true # Set to false to disable
Used for fact extraction, entity resolution, opinion generation, and answer synthesis.
**Supported providers:** Groq, OpenAI, Ollama, Gemini
**Supported providers:** Groq, OpenAI, Gemini, Ollama
| Provider | Recommended Model | Best For |
|----------|------------------|----------|
| **Groq** | `openai/gpt-oss-20b` | Fast inference, high throughput (recommended) |
| **OpenAI** | `gpt-5-mini` | Good quality |
| **Gemini** | `gemini-2.5-flash` | Good quality |
| **Ollama** | `gpt-oss-20b` | Local deployment, privacy |
| **Groq** | `openai/gpt-oss-20b` | Fast inference, high throughput (recommended) |
| **OpenAI** | `gpt-4o` | Good quality |
| **Gemini** | `gemini-2.0-flash` | Good quality, cost effective |
| **Ollama** | `llama3.1` | Local deployment, privacy |
**Configuration:**
@@ -86,28 +98,17 @@ export HINDSIGHT_API_LLM_MODEL=openai/gpt-oss-20b
# OpenAI
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-5-mini
export HINDSIGHT_API_LLM_MODEL=gpt-4o
# Gemini
export HINDSIGHT_API_LLM_PROVIDER=gemini
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gemini-2.5-flash
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
# Ollama (local)
export HINDSIGHT_API_LLM_PROVIDER=ollama
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
export HINDSIGHT_API_LLM_MODEL=gpt-oss-20b
export HINDSIGHT_API_LLM_MODEL=llama3.1
```
**Note:** The LLM is the primary bottleneck for write operations. See [Performance](./performance) for optimization strategies.
---
## Model Comparison
| Provider | Model | Speed | Quality | Cost |
|----------|-------|-------|---------|------|
| Groq | gpt-oss-20b | Fast | Good | Free tier |
| OpenAI | gpt-4o-mini | Medium | Good | $0.15 / $0.60 per 1M tokens |
| OpenAI | gpt-4o | Slower | Best | $2.50 / $10.00 per 1M tokens |
| Ollama | llama3.1 | Varies | Good | Free (local) |
**Note:** The LLM is the primary bottleneck for retain operations. See [Performance](./performance) for optimization strategies.
+11 -10
View File
@@ -6,6 +6,17 @@ sidebar_position: 4
When you call `reflect()`, Hindsight doesn't just retrieve facts — it **reasons** about them through the lens of the bank's unique disposition, forming new opinions and generating contextual responses.
```mermaid
graph LR
A[Query] --> B[Recall Memories]
B --> C[Load Disposition]
C --> D[Reason]
D --> E[Form Opinions]
E --> F[Response]
```
---
## Why Reflect?
Most AI systems can retrieve facts, but they can't **reason** about them in a consistent way. Every response is generated fresh without a stable perspective or evolving beliefs.
@@ -41,16 +52,6 @@ With reflect:
---
## The Reflect Process
1. **Recall** relevant memories based on the query
2. **Load** the bank's disposition traits and background
3. **Reason** about the memories through the disposition lens
4. **Form** new opinions with confidence scores
5. **Return** response, sources, and any new beliefs
---
## Disposition Traits
When you create a memory bank, you can configure its disposition using three traits. These traits influence how the bank interprets information and forms opinions during `reflect()`:
+6 -10
View File
@@ -8,16 +8,12 @@ When you call `retain()`, Hindsight transforms conversations and documents into
## What Retain Does
```
Your Content
Extract Rich Facts
Identify Entities
Build Connections
Searchable Memory Bank
```mermaid
graph LR
A[Your Content] --> B[Extract Facts]
B --> C[Identify Entities]
C --> D[Build Connections]
D --> E[Memory Bank]
```
---
+18 -13
View File
@@ -6,6 +6,24 @@ sidebar_position: 3
When you call `recall()`, Hindsight uses multiple search strategies in parallel to find the most relevant memories, regardless of how you phrase your query.
```mermaid
graph LR
Q[Query] --> S[Semantic]
Q --> K[Keyword]
Q --> G[Graph]
Q --> T[Temporal]
S --> RRF[RRF Fusion]
K --> RRF
G --> RRF
T --> RRF
RRF --> CE[Cross-Encoder]
CE --> R[Results]
```
---
## The Challenge of Memory Recall
Different queries need different search approaches:
@@ -117,19 +135,6 @@ This gives your agent richer context while maintaining precise control over tota
---
## How Recall Works
When you call `recall(query, bank_id)`:
1. **Parse** → Detect temporal expressions, understand intent
2. **Search** → Run 4 strategies in parallel
3. **Fuse** → Combine results, prioritizing consensus
4. **Rerank** → Neural reranking for final relevance
5. **Filter** → Select top memories within token budget
6. **Return** → Ranked, relevant memories
---
## Tuning Recall: Quality vs Latency
Different use cases require different trade-offs between **recall quality** and **response speed**. Two parameters control this:
+22 -2
View File
@@ -9,7 +9,7 @@ The Hindsight CLI provides command-line access to memory operations and bank man
## Installation
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```
## Configuration
@@ -179,12 +179,32 @@ hindsight memory recall <bank_id> "query" -o yaml
## Interactive Explorer
Launch the TUI explorer for visual navigation:
Launch the TUI explorer for visual navigation of your memory banks:
```bash
hindsight explore
```
The explorer provides an interactive terminal interface to:
- **Browse memory banks** — View all banks and their statistics
- **Search memories** — Run recall queries with real-time results
- **Inspect entities** — Explore the knowledge graph and entity relationships
- **View facts** — Browse world facts, experiences, and opinions
- **Navigate documents** — See source documents and their extracted memories
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `↑/↓` | Navigate items |
| `Enter` | Select / Expand |
| `Tab` | Switch panels |
| `/` | Search |
| `q` | Quit |
<!-- Screenshot placeholder: explore command TUI -->
## Example Workflow
```bash
-138
View File
@@ -1,138 +0,0 @@
---
sidebar_position: 3
---
# LangGraph
Hindsight provides a `BaseStore` implementation for LangGraph's memory system.
## Installation
```bash
cd hindsight-langmem && uv pip install -e .
```
## Quick Start
```python
from hindsight_langmem import HindsightStore
# Create store
store = HindsightStore(
base_url="http://localhost:8888",
default_agent_id="my-agent",
)
# Store data
store.put(
namespace=("user", "preferences"),
key="language",
value={"language": "Python", "reason": "data science"}
)
# Retrieve data
item = store.get(namespace=("user", "preferences"), key="language")
print(item.value) # {"language": "Python", "reason": "data science"}
# Search
results = store.search(
namespace_prefix=("user",),
query="programming language",
limit=10
)
```
## How It Works
`HindsightStore` implements LangGraph's `BaseStore` interface:
- **Namespaces** map to Hindsight agent IDs (joined with `__`)
- **Keys** map to document IDs
- **Values** are stored as JSON in memory content
## BaseStore Interface
### put
Store an item:
```python
store.put(
namespace=("user", "session-123"),
key="preferences",
value={"theme": "dark", "language": "en"}
)
```
### get
Retrieve an item:
```python
item = store.get(namespace=("user", "session-123"), key="preferences")
if item:
print(item.value) # {"theme": "dark", "language": "en"}
print(item.created_at)
print(item.updated_at)
```
### search
Search within a namespace:
```python
results = store.search(
namespace_prefix=("user",),
query="theme preferences",
limit=10,
offset=0
)
for item in results:
print(f"{item.key}: {item.value}")
```
### delete
Delete an item:
```python
store.delete(namespace=("user", "session-123"), key="preferences")
```
## Async Support
All operations have async variants:
```python
await store.aput(namespace, key, value)
item = await store.aget(namespace, key)
results = await store.asearch(namespace_prefix, query)
await store.adelete(namespace, key)
```
## With LangGraph
```python
from langgraph.graph import StateGraph
from hindsight_langmem import HindsightStore
store = HindsightStore(base_url="http://localhost:8888")
# Use store in your graph
graph = StateGraph()
# ... configure graph with store
```
## Namespace Mapping
Namespaces are converted to Hindsight agent IDs:
| Namespace | bank ID |
|-----------|----------|
| `("user",)` | `user` |
| `("user", "session")` | `user__session` |
| `("app", "v1", "data")` | `app__v1__data` |
| `()` | `default_agent_id` |
Memory banks are created automatically if they don't exist.
-89
View File
@@ -1,89 +0,0 @@
---
sidebar_position: 4
---
# MCP Server
Model Context Protocol server for AI assistants like Claude Desktop.
## Setup
The MCP server is included in the Hindsight API. When running the API with MCP enabled, it exposes MCP tools at `/mcp/{bank_id}/sse`.
### Claude Desktop Configuration
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"hindsight": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:8888/mcp/my-bank-id/sse"]
}
}
}
```
Replace `my-bank-id` with your memory bank ID.
## Available Tools
### retain
Store a memory:
```json
{
"name": "retain",
"arguments": {
"content": "User prefers Python for data analysis",
"context": "preferences"
}
}
```
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | yes | Memory content to store |
| `context` | string | no | Category (default: 'general') |
### recall
Search memories:
```json
{
"name": "recall",
"arguments": {
"query": "What does the user do for work?"
}
}
```
**Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | yes | Natural language search query |
| `max_results` | integer | no | Max results (default: 10) |
## Usage Example
Once configured, Claude can use Hindsight naturally:
**User**: "Remember that I prefer morning meetings"
**Claude**: *Uses retain*
> "I've noted that you prefer morning meetings."
---
**User**: "What do you know about my preferences?"
**Claude**: *Uses recall*
> "Based on our conversations, you prefer morning meetings and like Python for data analysis."
+13 -119
View File
@@ -15,21 +15,21 @@ npm install @vectorize-io/hindsight-client
## Quick Start
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
// Retain a memory
await client.retain('my-agent', 'Alice works at Google');
await client.retain('my-bank', 'Alice works at Google');
// Recall memories
const response = await client.recall('my-agent', 'What does Alice do?');
const response = await client.recall('my-bank', 'What does Alice do?');
for (const r of response.results) {
console.log(r.text);
}
// Reflect - generate response with disposition
const answer = await client.reflect('my-agent', 'Tell me about Alice');
const answer = await client.reflect('my-bank', 'Tell me about Alice');
console.log(answer.text);
```
@@ -49,10 +49,10 @@ const client = new HindsightClient({
```typescript
// Simple
await client.retain('my-agent', 'Alice works at Google');
await client.retain('my-bank', 'Alice works at Google');
// With options
await client.retain('my-agent', 'Alice got promoted', {
await client.retain('my-bank', 'Alice got promoted', {
timestamp: new Date('2024-01-15'),
context: 'career update',
metadata: { source: 'slack' },
@@ -63,11 +63,10 @@ await client.retain('my-agent', 'Alice got promoted', {
### Retain Batch
```typescript
await client.retainBatch('my-agent', [
await client.retainBatch('my-bank', [
{ content: 'Alice works at Google', context: 'career' },
{ content: 'Bob is a data scientist', context: 'career' },
], {
documentId: 'conversation_001',
async: false,
});
```
@@ -76,31 +75,29 @@ await client.retainBatch('my-agent', [
```typescript
// Simple - returns RecallResponse
const response = await client.recall('my-agent', 'What does Alice do?');
const response = await client.recall('my-bank', 'What does Alice do?');
for (const r of response.results) {
console.log(`${r.text} (type: ${r.type})`);
}
// With options
const response = await client.recall('my-agent', 'What does Alice do?', {
const response = await client.recall('my-bank', 'What does Alice do?', {
types: ['world', 'opinion'], // Filter by fact type
maxTokens: 4096,
budget: 'high', // 'low', 'mid', or 'high'
trace: true,
});
```
### Reflect (Generate Response)
```typescript
const answer = await client.reflect('my-agent', 'What should I know about Alice?', {
const answer = await client.reflect('my-bank', 'What should I know about Alice?', {
budget: 'low', // 'low', 'mid', or 'high'
context: 'preparing for a meeting',
});
console.log(answer.text); // Generated response
console.log(answer.based_on); // Memories used
```
## Bank Management
@@ -108,7 +105,7 @@ console.log(answer.based_on); // Memories used
### Create Bank
```typescript
await client.createBank('my-agent', {
await client.createBank('my-bank', {
name: 'Assistant',
background: 'I am a helpful AI assistant',
disposition: {
@@ -119,117 +116,14 @@ await client.createBank('my-agent', {
});
```
### Get Bank Profile
```typescript
const profile = await client.getBankProfile('my-agent');
console.log(profile.disposition);
console.log(profile.background);
```
### List Memories
```typescript
const response = await client.listMemories('my-agent', {
const response = await client.listMemories('my-bank', {
type: 'world', // Optional filter
q: 'Alice', // Optional text search
limit: 100,
offset: 0,
});
for (const memory of response.memories) {
console.log(`${memory.id}: ${memory.text}`);
}
```
## TypeScript Types
The client exports all types for full TypeScript support:
```typescript
import type {
RetainResponse,
RecallResponse,
RecallResult,
ReflectResponse,
BankProfileResponse,
Budget,
} from '@vectorize-io/hindsight-client';
// Budget is a union type: 'low' | 'mid' | 'high'
const budget: Budget = 'mid';
```
## Advanced: Low-Level SDK
For advanced use cases, access the auto-generated SDK directly:
```typescript
import { sdk, createClient, createConfig } from '@vectorize-io/hindsight-client';
const client = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// Use sdk functions directly
const response = await sdk.recallMemories({
client,
path: { bank_id: 'my-agent' },
body: {
query: 'What does Alice do?',
budget: 'mid',
max_tokens: 4096,
},
});
```
## Error Handling
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
try {
const response = await client.recall('unknown-agent', 'test');
} catch (error) {
console.error('Error:', error.message);
}
```
## Example: Full Workflow
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
async function main() {
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
// Create a bank with disposition
await client.createBank('demo', {
name: 'Demo Agent',
background: 'A helpful assistant for demos',
disposition: {
skepticism: 2, // Trusting
literalism: 3, // Balanced
empathy: 4, // Empathetic
},
});
// Store some memories
await client.retain('demo', 'Alice works at Google');
await client.retain('demo', 'Bob is a data scientist at Google');
await client.retain('demo', 'Alice and Bob collaborate on ML projects');
// Search for memories
const searchResults = await client.recall('demo', 'Who works at Google?');
console.log('Search results:');
for (const r of searchResults.results) {
console.log(` - ${r.text}`);
}
// Generate a response
const answer = await client.reflect('demo', 'What do you know about the team?');
console.log('\nReflection:', answer.text);
}
main().catch(console.error);
console.log(response)
```
-112
View File
@@ -1,112 +0,0 @@
---
sidebar_position: 2
---
# OpenAI
Drop-in replacement for the OpenAI Python client with automatic memory integration.
## Installation
```bash
cd hindsight-openai && uv pip install -e .
```
## Quick Start
```python
from hindsight_openai import configure, OpenAI
# Configure once
configure(
hindsight_api_url="http://localhost:8888",
agent_id="my-agent",
)
# Use OpenAI client normally
client = OpenAI(api_key="sk-...")
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What did we discuss about AI?"}]
)
```
## How It Works
The wrapper intercepts OpenAI calls:
1. **Before**: Retrieves relevant memories and injects as system message
2. **After**: Stores conversation to Hindsight
Your code works exactly as before, but now has memory.
## Configuration
```python
configure(
hindsight_api_url="http://localhost:8888", # Hindsight API
agent_id="my-agent", # Required
store_conversations=True, # Store conversations
inject_memories=True, # Inject memories into prompts
document_id="session-123", # Group by document
enabled=True, # Master switch
)
```
## Memory Injection
When enabled, memories are automatically injected:
```python
# Your code
messages = [{"role": "user", "content": "What trails did Alice recommend?"}]
# What gets sent to OpenAI
messages = [
{
"role": "system",
"content": "Relevant context:\n- Alice loves hiking in Yosemite\n- Alice recommended Half Dome trail"
},
{"role": "user", "content": "What trails did Alice recommend?"}
]
```
## Async Support
```python
from hindsight_openai import configure, AsyncOpenAI
configure(hindsight_api_url="http://localhost:8888", agent_id="my-agent")
client = AsyncOpenAI(api_key="sk-...")
response = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me about my preferences"}]
)
```
## Streaming
Fully supported:
```python
stream = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me a story"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
```
## Disable Temporarily
```python
from hindsight_openai import configure
configure(enabled=False) # Disable
configure(enabled=True) # Re-enable
```
+25 -46
View File
@@ -49,15 +49,15 @@ with HindsightServer(
client = HindsightClient(base_url=server.url)
# Retain a memory
client.retain(bank_id="my-agent", content="Alice works at Google")
client.retain(bank_id="my-bank", content="Alice works at Google")
# Recall memories
results = client.recall(bank_id="my-agent", query="What does Alice do?")
results = client.recall(bank_id="my-bank", query="What does Alice do?")
for r in results:
print(r.text)
# Reflect - generate response with disposition
answer = client.reflect(bank_id="my-agent", query="Tell me about Alice")
answer = client.reflect(bank_id="my-bank", query="Tell me about Alice")
print(answer.text)
```
@@ -70,15 +70,15 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Retain a memory
client.retain(bank_id="my-agent", content="Alice works at Google")
client.retain(bank_id="my-bank", content="Alice works at Google")
# Recall memories
results = client.recall(bank_id="my-agent", query="What does Alice do?")
results = client.recall(bank_id="my-bank", query="What does Alice do?")
for r in results:
print(r.text)
# Reflect - generate response with disposition
answer = client.reflect(bank_id="my-agent", query="Tell me about Alice")
answer = client.reflect(bank_id="my-bank", query="Tell me about Alice")
print(answer.text)
```
@@ -103,7 +103,7 @@ client = Hindsight(
```python
# Simple
client.retain(
bank_id="my-agent",
bank_id="my-bank",
content="Alice works at Google as a software engineer",
)
@@ -111,7 +111,7 @@ client.retain(
from datetime import datetime
client.retain(
bank_id="my-agent",
bank_id="my-bank",
content="Alice got promoted",
context="career update",
timestamp=datetime(2024, 1, 15),
@@ -124,7 +124,7 @@ client.retain(
```python
client.retain_batch(
bank_id="my-agent",
bank_id="my-bank",
items=[
{"content": "Alice works at Google", "context": "career"},
{"content": "Bob is a data scientist", "context": "career"},
@@ -139,16 +139,16 @@ client.retain_batch(
```python
# Simple - returns list of RecallResult
results = client.recall(
bank_id="my-agent",
bank_id="my-bank",
query="What does Alice do?",
)
for r in results:
for r in results.results:
print(f"{r.text} (type: {r.type})")
# With options
results = client.recall(
bank_id="my-agent",
bank_id="my-bank",
query="What does Alice do?",
types=["world", "opinion"], # Filter by fact type
max_tokens=4096,
@@ -159,16 +159,15 @@ results = client.recall(
### Recall with Full Response
```python
# Returns RecallResponse with entities and trace info
response = client.recall_memories(
bank_id="my-agent",
# Returns RecallResponse with entities and chunks
response = client.recall(
bank_id="my-bank",
query="What does Alice do?",
types=["world", "experience"],
budget="mid",
max_tokens=4096,
trace=True,
include_entities=True,
max_entity_tokens=500,
max_entity_tokens=500
)
print(f"Found {len(response.results)} memories")
@@ -185,14 +184,13 @@ if response.entities:
```python
answer = client.reflect(
bank_id="my-agent",
bank_id="my-bank",
query="What should I know about Alice?",
budget="low", # low, mid, or high
context="preparing for a meeting",
)
print(answer.text) # Generated response
print(answer.based_on) # Memories used
```
## Bank Management
@@ -201,7 +199,7 @@ print(answer.based_on) # Memories used
```python
client.create_bank(
bank_id="my-agent",
bank_id="my-bank",
name="Assistant",
background="I am a helpful AI assistant",
disposition={
@@ -215,16 +213,13 @@ client.create_bank(
### List Memories
```python
response = client.list_memories(
bank_id="my-agent",
client.list_memories(
bank_id="my-bank",
type="world", # Optional: filter by type
search_query="Alice", # Optional: text search
limit=100,
offset=0,
)
for memory in response.memories:
print(f"{memory.id}: {memory.text}")
```
## Async Support
@@ -239,15 +234,15 @@ async def main():
client = Hindsight(base_url="http://localhost:8888")
# Async retain
await client.aretain(bank_id="my-agent", content="Hello world")
await client.aretain(bank_id="my-bank", content="Hello world")
# Async recall
results = await client.arecall(bank_id="my-agent", query="Hello")
results = await client.arecall(bank_id="my-bank", query="Hello")
for r in results:
print(r.text)
# Async reflect
answer = await client.areflect(bank_id="my-agent", query="What did I say?")
answer = await client.areflect(bank_id="my-bank", query="What did I say?")
print(answer.text)
client.close()
@@ -255,29 +250,13 @@ async def main():
asyncio.run(main())
```
## Response Types
The client exports response types for type hints:
```python
from hindsight_client import (
Hindsight,
RetainResponse,
RecallResponse,
RecallResult,
ReflectResponse,
BankProfileResponse,
DispositionTraits,
)
```
## Context Manager
```python
from hindsight_client import Hindsight
with Hindsight(base_url="http://localhost:8888") as client:
client.retain(bank_id="my-agent", content="Hello")
results = client.recall(bank_id="my-agent", query="Hello")
client.retain(bank_id="my-bank", content="Hello")
results = client.recall(bank_id="my-bank", query="Hello")
# Client automatically closed
```
+53 -38
View File
@@ -5,7 +5,7 @@ import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'Hindsight',
tagline: 'Entity-Aware Memory System for AI Agents',
favicon: 'img/favicon.ico',
favicon: 'img/favicon.png',
future: {
v4: true,
@@ -49,7 +49,7 @@ const config: Config = {
tagName: 'link',
attributes: {
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Nunito+Sans:wght@400;500;600;700;800&display=swap',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap',
media: 'print',
onload: "this.media='all'",
},
@@ -83,7 +83,7 @@ const config: Config = {
},
],
theme: {
primaryColor: '#0d9488',
primaryColor: '#0074d9',
sidebar: {
backgroundColor: '#09090b',
},
@@ -92,9 +92,9 @@ const config: Config = {
},
typography: {
fontSize: '15px',
fontFamily: "'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
headings: {
fontFamily: "'Avenir', 'Avenir Book', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamily: "'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
code: {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace",
@@ -121,51 +121,51 @@ const config: Config = {
respectPrefersColorScheme: true,
},
navbar: {
title: 'Hindsight',
logo: {
alt: 'Hindsight Logo',
src: 'img/logo.svg',
src: 'img/logo.png',
style: { height: '32px' },
},
items: [
{
type: 'custom-iconLink',
type: 'doc',
docId: 'developer/index',
position: 'left',
icon: 'code',
label: 'Developer',
to: '/',
className: 'navbar-item-developer',
},
{
type: 'custom-iconLink',
type: 'doc',
docId: 'sdks/python',
position: 'left',
icon: 'package',
label: 'SDKs',
to: '/sdks/python',
className: 'navbar-item-sdks',
},
{
type: 'custom-iconLink',
position: 'left',
icon: 'file-code',
label: 'API Reference',
to: '/api-reference',
position: 'left',
label: 'API Reference',
className: 'navbar-item-api',
},
{
type: 'custom-iconLink',
type: 'doc',
docId: 'cookbook/index',
position: 'left',
icon: 'book-open',
label: 'Cookbook',
to: '/cookbook',
className: 'navbar-item-cookbook',
},
{
type: 'custom-iconLink',
type: 'doc',
docId: 'changelog/index',
position: 'left',
icon: 'clock',
label: 'Changelog',
to: '/changelog',
className: 'navbar-item-changelog',
},
{
href: 'https://github.com/vectorize-io/hindsight',
label: 'GitHub',
position: 'right',
className: 'header-github-link',
'aria-label': 'GitHub repository',
},
],
},
@@ -199,7 +199,7 @@ const config: Config = {
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} Hindsight. Built with Docusaurus.`,
copyright: `Copyright © ${new Date().getFullYear()} Hindsight.`,
},
prism: {
theme: prismThemes.github,
@@ -209,24 +209,39 @@ const config: Config = {
mermaid: {
theme: {
light: 'base',
dark: 'dark',
dark: 'base',
},
options: {
themeVariables: {
primaryColor: '#6366f1',
// Gradient start (#0074d9 blue) for nodes
primaryColor: '#0074d9',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4f46e5',
secondaryColor: '#f1f5f9',
secondaryTextColor: '#1e293b',
secondaryBorderColor: '#cbd5e1',
tertiaryColor: '#e0e7ff',
lineColor: '#94a3b8',
primaryBorderColor: '#005db0',
// Gradient end (#009296 teal) for edges/clusters
secondaryColor: '#009296',
secondaryTextColor: '#ffffff',
secondaryBorderColor: '#007a7d',
// Tertiary
tertiaryColor: '#e6f7f8',
tertiaryTextColor: '#1e293b',
// Lines and edges - gradient end color
lineColor: '#009296',
// Text
textColor: '#1e293b',
mainBkg: '#ffffff',
nodeBorder: '#4f46e5',
clusterBkg: '#f8fafc',
clusterBorder: '#e2e8f0',
fontFamily: 'system-ui, -apple-system, sans-serif',
// Node specific - gradient start
nodeBkg: '#0074d9',
nodeTextColor: '#ffffff',
nodeBorder: '#005db0',
// Main background
mainBkg: '#0074d9',
// Clusters/subgraphs - gradient end
clusterBkg: 'rgba(0, 146, 150, 0.08)',
clusterBorder: '#009296',
// Labels
edgeLabelBackground: 'transparent',
labelBackground: 'transparent',
// Font - Inter to match body text
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
},
},
+66 -1
View File
@@ -1046,6 +1046,47 @@
}
}
}
},
"delete": {
"tags": [
"Banks"
],
"summary": "Delete memory bank",
"description": "Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. This is a destructive operation that cannot be undone.",
"operationId": "delete_bank",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/memories": {
@@ -1494,6 +1535,28 @@
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Message"
},
"deleted_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Deleted Count"
}
},
"type": "object",
@@ -1503,7 +1566,9 @@
"title": "DeleteResponse",
"description": "Response model for delete operations.",
"example": {
"success": true
"success": true,
"message": "Deleted successfully",
"deleted_count": 10
}
},
"DispositionTraits": {
-14
View File
@@ -13,7 +13,6 @@
"@docusaurus/theme-common": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@mdx-js/react": "^3.0.0",
"@phosphor-icons/react": "^2.1.10",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
@@ -4534,19 +4533,6 @@
"node": ">=8.0.0"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
-1
View File
@@ -21,7 +21,6 @@
"@docusaurus/theme-common": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@mdx-js/react": "^3.0.0",
"@phosphor-icons/react": "^2.1.10",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
-22
View File
@@ -147,28 +147,6 @@ const sidebars: SidebarsConfig = {
},
],
},
{
type: 'category',
label: 'Integrations',
collapsible: false,
items: [
{
type: 'doc',
id: 'sdks/openai',
label: 'OpenAI',
},
{
type: 'doc',
id: 'sdks/langgraph',
label: 'LangGraph',
},
{
type: 'doc',
id: 'sdks/mcp',
label: 'MCP Server',
},
],
},
],
cookbookSidebar: [
{
@@ -1,42 +0,0 @@
import React from 'react';
import Link from '@docusaurus/Link';
import {
House,
Code,
Package,
FileCode,
BookOpen,
ClockCounterClockwise,
} from '@phosphor-icons/react';
const iconMap = {
house: House,
code: Code,
package: Package,
'file-code': FileCode,
'book-open': BookOpen,
clock: ClockCounterClockwise,
};
export default function NavbarIconLink({
icon,
label,
to,
className,
}: {
icon: keyof typeof iconMap;
label: string;
to: string;
className?: string;
}) {
const IconComponent = iconMap[icon];
return (
<Link to={to} className={`navbar__link ${className || ''}`}>
{IconComponent && (
<IconComponent size={16} weight="bold" style={{ marginRight: '6px', verticalAlign: 'middle' }} />
)}
<span style={{ verticalAlign: 'middle' }}>{label}</span>
</Link>
);
}
+518 -53
View File
@@ -2,43 +2,49 @@
* Hindsight custom theme - Modern, clean style
*/
/* Primary colors - Teal/Cyan theme */
/* Primary colors - Blue to Teal gradient theme */
:root {
--ifm-color-primary: #0d9488;
--ifm-color-primary-dark: #0f766e;
--ifm-color-primary-darker: #115e59;
--ifm-color-primary-darkest: #134e4a;
--ifm-color-primary-light: #14b8a6;
--ifm-color-primary-lighter: #2dd4bf;
--ifm-color-primary-lightest: #5eead4;
/* Primary gradient */
--hindsight-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%);
--hindsight-gradient-start: #0074d9;
--hindsight-gradient-end: #009296;
/* Fallback solid colors (midpoint of gradient) */
--ifm-color-primary: #0074d9;
--ifm-color-primary-dark: #0068c3;
--ifm-color-primary-darker: #005db0;
--ifm-color-primary-darkest: #004d91;
--ifm-color-primary-light: #1a85e0;
--ifm-color-primary-lighter: #3396e8;
--ifm-color-primary-lightest: #e6f3ff;
--ifm-code-font-size: 90%;
--docusaurus-highlighted-code-line-bg: rgba(13, 148, 136, 0.1);
--docusaurus-highlighted-code-line-bg: rgba(0, 116, 217, 0.1);
/* Typography - Avenir Book */
--ifm-font-family-base: 'Avenir Book', 'Avenir', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-heading-font-family: 'Avenir', 'Avenir Book', 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-heading-font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--ifm-font-family-monospace: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
--ifm-font-weight-semibold: 600;
--ifm-font-size-base: 96%;
/* Spacing */
--ifm-spacing-horizontal: 1.5rem;
--ifm-navbar-height: 3.5rem;
--ifm-navbar-height: 4.5rem;
/* Borders */
--ifm-global-radius: 0.5rem;
}
[data-theme='dark'] {
--ifm-color-primary: #2dd4bf;
--ifm-color-primary-dark: #14b8a6;
--ifm-color-primary-darker: #0d9488;
--ifm-color-primary-darkest: #0f766e;
--ifm-color-primary-light: #5eead4;
--ifm-color-primary-lighter: #99f6e4;
--ifm-color-primary-lightest: #ccfbf1;
--docusaurus-highlighted-code-line-bg: rgba(45, 212, 191, 0.15);
--ifm-color-primary: #3396e8;
--ifm-color-primary-dark: #1a85e0;
--ifm-color-primary-darker: #0074d9;
--ifm-color-primary-darkest: #0068c3;
--ifm-color-primary-light: #66b3f0;
--ifm-color-primary-lighter: #99cff5;
--ifm-color-primary-lightest: rgba(51, 150, 232, 0.15);
--docusaurus-highlighted-code-line-bg: rgba(51, 150, 232, 0.15);
--ifm-background-color: #09090b;
--ifm-background-surface-color: #18181b;
@@ -47,6 +53,14 @@
--ifm-toc-border-color: #27272a;
}
/* Gradient text utility */
.gradient-text {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Navbar styling */
.navbar {
box-shadow: none;
@@ -54,9 +68,17 @@
padding: 0 1rem;
}
.navbar__logo {
margin-bottom: 0.5rem;
}
.navbar__title {
font-weight: 700;
font-size: 1.125rem;
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.navbar__items {
@@ -64,32 +86,202 @@
}
.navbar__link {
font-weight: 500;
font-size: 0.8125rem;
padding: 0.5rem 0.75rem;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 700;
font-size: 0.9rem;
padding: 0.625rem 1rem;
border-radius: 0.375rem;
transition: background-color 0.15s ease;
}
/* Navbar icons (desktop only) */
@media (min-width: 997px) {
.navbar-item-developer::before,
.navbar-item-sdks::before,
.navbar-item-api::before,
.navbar-item-cookbook::before,
.navbar-item-changelog::before {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: middle;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
content: '';
}
.navbar-item-developer::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23666' d='M71.68 97.22 34.74 128l36.94 30.78a12 12 0 1 1-15.36 18.44l-48-40a12 12 0 0 1 0-18.44l48-40a12 12 0 0 1 15.36 18.44Zm176 21.56-48-40a12 12 0 1 0-15.36 18.44L221.26 128l-36.94 30.78a12 12 0 1 0 15.36 18.44l48-40a12 12 0 0 0 0-18.44ZM164.1 28.72a12 12 0 0 0-15.38 7.18l-64 176a12 12 0 0 0 7.18 15.37 11.79 11.79 0 0 0 4.1.73 12 12 0 0 0 11.28-7.9l64-176a12 12 0 0 0-7.18-15.38Z'/%3E%3C/svg%3E");
}
.navbar-item-sdks::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23666' d='m225.6 62.64-88-48.17a19.91 19.91 0 0 0-19.2 0l-88 48.17A20 20 0 0 0 20 80.19v95.62a20 20 0 0 0 10.4 17.55l88 48.17a19.89 19.89 0 0 0 19.2 0l88-48.17a20 20 0 0 0 10.4-17.55V80.19a20 20 0 0 0-10.4-17.55ZM128 36.57 200 76l-72 39.42L56 76ZM44 96.82l72 39.43v76.89l-72-39.42Zm96 116.32v-76.89l72-39.43v76.89Z'/%3E%3C/svg%3E");
}
.navbar-item-api::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23666' d='M180.49 143.51a12 12 0 0 1 0 17l-24 24a12 12 0 0 1-17-17L155 152l-15.52-15.51a12 12 0 0 1 17-17ZM112.49 120.49a12 12 0 0 0-17 0l-24 24a12 12 0 0 0 0 17l24 24a12 12 0 0 0 17-17L97 153l15.52-15.51a12 12 0 0 0-.03-17ZM220 88v24a12 12 0 0 1-24 0v-16h-44a12 12 0 0 1-12-12V40H60v68a12 12 0 0 1-24 0V40a20 20 0 0 1 20-20h96a12 12 0 0 1 8.49 3.52l56 56A12 12 0 0 1 220 88Zm-60-8h23L160 57Zm-4 132H60v-12a12 12 0 0 0-24 0v12a20 20 0 0 0 20 20h100a12 12 0 0 0 0-24Zm64-44a12 12 0 0 0-12 12v36h-44a12 12 0 0 0 0 24h44a20 20 0 0 0 20-20v-40a12 12 0 0 0-8-12Z'/%3E%3C/svg%3E");
}
.navbar-item-cookbook::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23666' d='M224 44H160a43.86 43.86 0 0 0-32 13.85A43.86 43.86 0 0 0 96 44H32a20 20 0 0 0-20 20v128a20 20 0 0 0 20 20h64a20 20 0 0 1 20 20 12 12 0 0 0 24 0 20 20 0 0 1 20-20h64a20 20 0 0 0 20-20V64a20 20 0 0 0-20-20ZM96 188H36V68h60a20 20 0 0 1 20 20v108.69A43.74 43.74 0 0 0 96 188Zm124 0h-60a43.74 43.74 0 0 0-20 8.69V88a20 20 0 0 1 20-20h60Z'/%3E%3C/svg%3E");
}
.navbar-item-changelog::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23666' d='M140 80v41.21l34.17 20.5a12 12 0 1 1-12.34 20.58l-40-24A12 12 0 0 1 116 128V80a12 12 0 0 1 24 0Zm-12-52a99.38 99.38 0 0 0-70.76 29.34c-4.69 4.74-9 9.37-13.24 14V64a12 12 0 0 0-24 0v40a12 12 0 0 0 12 12h40a12 12 0 0 0 0-24H53.41c4.24-5.95 8.53-11.93 13.49-16.95A76 76 0 1 1 52 128a12 12 0 0 0-24 0 100 100 0 1 0 100-100Z'/%3E%3C/svg%3E");
}
/* Dark mode icons */
[data-theme='dark'] .navbar-item-developer::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23ccc' d='M71.68 97.22 34.74 128l36.94 30.78a12 12 0 1 1-15.36 18.44l-48-40a12 12 0 0 1 0-18.44l48-40a12 12 0 0 1 15.36 18.44Zm176 21.56-48-40a12 12 0 1 0-15.36 18.44L221.26 128l-36.94 30.78a12 12 0 1 0 15.36 18.44l48-40a12 12 0 0 0 0-18.44ZM164.1 28.72a12 12 0 0 0-15.38 7.18l-64 176a12 12 0 0 0 7.18 15.37 11.79 11.79 0 0 0 4.1.73 12 12 0 0 0 11.28-7.9l64-176a12 12 0 0 0-7.18-15.38Z'/%3E%3C/svg%3E");
}
[data-theme='dark'] .navbar-item-sdks::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23ccc' d='m225.6 62.64-88-48.17a19.91 19.91 0 0 0-19.2 0l-88 48.17A20 20 0 0 0 20 80.19v95.62a20 20 0 0 0 10.4 17.55l88 48.17a19.89 19.89 0 0 0 19.2 0l88-48.17a20 20 0 0 0 10.4-17.55V80.19a20 20 0 0 0-10.4-17.55ZM128 36.57 200 76l-72 39.42L56 76ZM44 96.82l72 39.43v76.89l-72-39.42Zm96 116.32v-76.89l72-39.43v76.89Z'/%3E%3C/svg%3E");
}
[data-theme='dark'] .navbar-item-api::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23ccc' d='M180.49 143.51a12 12 0 0 1 0 17l-24 24a12 12 0 0 1-17-17L155 152l-15.52-15.51a12 12 0 0 1 17-17ZM112.49 120.49a12 12 0 0 0-17 0l-24 24a12 12 0 0 0 0 17l24 24a12 12 0 0 0 17-17L97 153l15.52-15.51a12 12 0 0 0-.03-17ZM220 88v24a12 12 0 0 1-24 0v-16h-44a12 12 0 0 1-12-12V40H60v68a12 12 0 0 1-24 0V40a20 20 0 0 1 20-20h96a12 12 0 0 1 8.49 3.52l56 56A12 12 0 0 1 220 88Zm-60-8h23L160 57Zm-4 132H60v-12a12 12 0 0 0-24 0v12a20 20 0 0 0 20 20h100a12 12 0 0 0 0-24Zm64-44a12 12 0 0 0-12 12v36h-44a12 12 0 0 0 0 24h44a20 20 0 0 0 20-20v-40a12 12 0 0 0-8-12Z'/%3E%3C/svg%3E");
}
[data-theme='dark'] .navbar-item-cookbook::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23ccc' d='M224 44H160a43.86 43.86 0 0 0-32 13.85A43.86 43.86 0 0 0 96 44H32a20 20 0 0 0-20 20v128a20 20 0 0 0 20 20h64a20 20 0 0 1 20 20 12 12 0 0 0 24 0 20 20 0 0 1 20-20h64a20 20 0 0 0 20-20V64a20 20 0 0 0-20-20ZM96 188H36V68h60a20 20 0 0 1 20 20v108.69A43.74 43.74 0 0 0 96 188Zm124 0h-60a43.74 43.74 0 0 0-20 8.69V88a20 20 0 0 1 20-20h60Z'/%3E%3C/svg%3E");
}
[data-theme='dark'] .navbar-item-changelog::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='%23ccc' d='M140 80v41.21l34.17 20.5a12 12 0 1 1-12.34 20.58l-40-24A12 12 0 0 1 116 128V80a12 12 0 0 1 24 0Zm-12-52a99.38 99.38 0 0 0-70.76 29.34c-4.69 4.74-9 9.37-13.24 14V64a12 12 0 0 0-24 0v40a12 12 0 0 0 12 12h40a12 12 0 0 0 0-24H53.41c4.24-5.95 8.53-11.93 13.49-16.95A76 76 0 1 1 52 128a12 12 0 0 0-24 0 100 100 0 1 0 100-100Z'/%3E%3C/svg%3E");
}
}
/* GitHub icon link */
.header-github-link::before {
content: '';
width: 24px;
height: 24px;
display: flex;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23666' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
}
[data-theme='dark'] .header-github-link::before {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23fff' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
}
.header-github-link:hover {
opacity: 0.7;
}
.navbar__link:hover {
background-color: var(--ifm-background-surface-color);
}
.navbar__link--active {
color: var(--ifm-color-primary);
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-theme='dark'] .navbar__link:hover {
background-color: #27272a;
}
/* Mobile navbar */
@media (max-width: 996px) {
:root {
--ifm-navbar-height: 3.5rem;
}
.navbar {
padding: 0 0.75rem;
}
.navbar__logo {
margin-bottom: 0;
height: 24px !important;
}
.navbar__logo img {
height: 24px !important;
}
/* Hamburger menu toggle */
.navbar__toggle {
color: var(--ifm-color-primary);
}
/* Mobile sidebar */
.navbar-sidebar {
background: #ffffff !important;
}
.navbar-sidebar__brand {
padding: 1rem;
border-bottom: 1px solid var(--ifm-toc-border-color);
background: #ffffff !important;
}
.navbar-sidebar__items {
padding: 1rem 0;
background: #ffffff !important;
}
.navbar-sidebar .menu__link {
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 600;
font-size: 1rem;
padding: 0.75rem 1.25rem;
color: #1e293b !important;
}
.navbar-sidebar .menu__link--active {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Close button */
.navbar-sidebar__close {
color: #1e293b !important;
}
/* Backdrop overlay */
.navbar-sidebar__backdrop {
background: rgba(0, 0, 0, 0.5) !important;
}
}
/* Dark mode mobile sidebar */
@media (max-width: 996px) {
[data-theme='dark'] .navbar-sidebar {
background: #09090b !important;
}
[data-theme='dark'] .navbar-sidebar__brand {
background: #09090b !important;
}
[data-theme='dark'] .navbar-sidebar__items {
background: #09090b !important;
}
[data-theme='dark'] .navbar-sidebar .menu__link {
color: #e2e8f0 !important;
}
[data-theme='dark'] .navbar-sidebar__close {
color: #e2e8f0 !important;
}
}
/* Hero section */
.hero {
padding: 4rem 0;
}
.hero--primary {
background: linear-gradient(135deg, var(--ifm-color-primary-darkest) 0%, var(--ifm-color-primary-dark) 100%);
background: linear-gradient(135deg, var(--hindsight-gradient-start) 0%, var(--hindsight-gradient-end) 100%);
}
[data-theme='dark'] .hero--primary {
@@ -115,18 +307,26 @@
}
.button--primary {
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--hindsight-gradient);
border: none;
color: white;
}
.button--primary:hover {
background: var(--ifm-color-primary-dark);
border-color: var(--ifm-color-primary-dark);
background: linear-gradient(135deg, #005db0 0%, #007a7d 100%);
color: white;
}
.button--secondary {
background: transparent;
border: 2px solid currentColor;
border: 2px solid var(--hindsight-gradient-start);
color: var(--ifm-color-primary);
}
.button--secondary:hover {
background: var(--hindsight-gradient);
border-color: transparent;
color: white;
}
/* Sidebar */
@@ -137,13 +337,18 @@
}
.menu__link--active {
background: var(--ifm-color-primary-lightest);
color: var(--ifm-color-primary-darkest);
background: linear-gradient(135deg, rgba(0, 116, 217, 0.1) 0%, rgba(0, 146, 150, 0.1) 100%);
}
.menu__link--active .menu__link {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-theme='dark'] .menu__link--active {
background: rgba(45, 212, 191, 0.12);
color: var(--ifm-color-primary-light);
background: linear-gradient(135deg, rgba(0, 116, 217, 0.15) 0%, rgba(0, 146, 150, 0.15) 100%);
}
/* Non-collapsible category styling */
@@ -174,11 +379,16 @@
}
/* Code blocks */
pre,
pre code,
.prism-code {
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace) !important;
}
.prism-code {
border-radius: 0.5rem;
font-size: 0.875rem;
line-height: 1.6;
font-family: var(--ifm-font-family-monospace);
padding: 1rem !important;
}
@@ -187,7 +397,7 @@ code {
border-radius: 0.375rem;
padding: 0.2rem 0.45rem;
font-size: 0.875em;
font-family: var(--ifm-font-family-monospace);
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace) !important;
background-color: #f1f5f9;
color: #0f172a;
font-weight: 500;
@@ -200,7 +410,7 @@ code {
/* Don't apply inline styles to code inside pre blocks */
pre code {
background-color: transparent;
background-color: transparent !important;
border: none;
padding: 0;
font-size: inherit;
@@ -208,32 +418,36 @@ pre code {
font-weight: normal;
}
/* Code block container */
/* Code block container - single background source */
div[class*="codeBlockContainer"] {
border-radius: 0.5rem;
border: 1px solid var(--ifm-toc-border-color);
overflow: hidden;
background: #f8fafc;
background: #f8fafc !important;
}
[data-theme='dark'] div[class*="codeBlockContainer"] {
background: #0f172a;
background: #0f172a !important;
}
div[class*="codeBlockTitle"] {
font-size: 0.75rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--ifm-toc-border-color);
font-family: var(--ifm-font-family-monospace);
font-family: 'JetBrains Mono', var(--ifm-font-family-monospace);
background: transparent !important;
}
/* Code block content area */
div[class*="codeBlockContent"] {
background: #f8fafc;
/* Code block content area - transparent to show container bg */
div[class*="codeBlockContent"],
div[class*="codeBlockContent"] pre,
div[class*="codeBlockContent"] .prism-code {
background: transparent !important;
}
[data-theme='dark'] div[class*="codeBlockContent"] {
background: #0f172a;
/* Prism token backgrounds */
.prism-code span {
background: transparent !important;
}
/* Cards/Features */
@@ -246,10 +460,21 @@ div[class*="codeBlockContent"] {
max-width: 100%;
}
article h1 {
/* Page title with gradient */
article h1,
.markdown h1,
header h1,
h1[class*="title"] {
font-size: 2.25rem;
font-weight: 800;
margin-bottom: 1.25rem;
background-image: linear-gradient(90deg, #0074d9, #009296) !important;
background-size: 100% !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
color: transparent !important;
display: inline-block;
}
article h2 {
@@ -258,7 +483,9 @@ article h2 {
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--ifm-toc-border-color);
border-bottom: 2px solid transparent;
border-image: var(--hindsight-gradient);
border-image-slice: 1;
}
article h3 {
@@ -272,10 +499,93 @@ article p {
line-height: 1.7;
}
/* Admonitions */
.admonition {
border-radius: 0.5rem;
border-left-width: 4px;
/* Links with gradient */
article a:not(.button):not([class*="hash-link"]) {
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-decoration: none;
font-weight: 500;
}
article a:not(.button):not([class*="hash-link"]):hover {
text-decoration: underline;
text-decoration-color: var(--hindsight-gradient-start);
}
/* Admonitions - gradient themed */
.theme-admonition,
[class*="admonition_"] {
border-radius: 0.5rem !important;
border-left: none !important;
border: none !important;
background: rgba(0, 116, 217, 0.05) !important;
position: relative !important;
overflow: hidden !important;
padding-left: 1.25rem !important;
}
/* Gradient left border using pseudo-element */
.theme-admonition::before,
[class*="admonition_"]::before {
content: '' !important;
position: absolute !important;
left: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 4px !important;
background: linear-gradient(180deg, #0074d9, #009296) !important;
border-radius: 0.5rem 0 0 0.5rem !important;
}
/* Admonition heading text - gradient */
[class*="admonitionHeading_"] {
background-image: linear-gradient(90deg, #0074d9, #009296) !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
color: transparent !important;
display: inline-flex !important;
align-items: center !important;
}
/* Admonition icon - gradient start color */
[class*="admonitionIcon_"] svg,
[class*="admonitionIcon_"] svg path {
fill: #0074d9 !important;
}
/* Different admonition types */
.alert--info[class*="admonition_"],
.theme-admonition-info {
background: rgba(0, 116, 217, 0.05) !important;
}
.alert--success[class*="admonition_"],
.theme-admonition-tip {
background: rgba(0, 146, 150, 0.05) !important;
}
.alert--warning[class*="admonition_"],
.theme-admonition-warning {
background: rgba(0, 116, 217, 0.08) !important;
}
.alert--secondary[class*="admonition_"],
.theme-admonition-note {
background: rgba(0, 131, 154, 0.05) !important;
}
/* Dark mode admonitions */
[data-theme='dark'] .theme-admonition,
[data-theme='dark'] [class*="admonition_"] {
background: rgba(0, 131, 154, 0.1) !important;
}
[data-theme='dark'] .theme-admonition::before,
[data-theme='dark'] [class*="admonition_"]::before {
background: linear-gradient(180deg, #3396e8, #00b4b8) !important;
}
/* Tables - Compact styling */
@@ -318,6 +628,14 @@ th {
padding: 0.5rem 1rem;
}
.tabs__item--active {
border-bottom-color: var(--hindsight-gradient-start);
background: var(--hindsight-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Redoc sidebar - expand all tags by default */
[class*="redoc-wrap"] [class*="menu-content"] ul {
@@ -329,6 +647,153 @@ th {
display: none !important;
}
/* Mermaid diagram styling - using high specificity selectors */
.mermaid svg[id^="mermaid"] {
background: transparent !important;
}
/* Node shapes - gradient start color (#0074d9 blue) */
.mermaid svg[id^="mermaid"] .node rect,
.mermaid svg[id^="mermaid"] .node circle,
.mermaid svg[id^="mermaid"] .node ellipse,
.mermaid svg[id^="mermaid"] .node polygon,
.mermaid svg[id^="mermaid"] .node path,
svg[id^="mermaid"] .node rect,
svg[id^="mermaid"] .node circle,
svg[id^="mermaid"] .node ellipse,
svg[id^="mermaid"] .node polygon,
svg[id^="mermaid"] .node path {
fill: #0074d9 !important;
stroke: #005db0 !important;
}
/* Node text - white on colored background, Inter font */
.mermaid svg[id^="mermaid"] .node .label,
.mermaid svg[id^="mermaid"] .nodeLabel,
.mermaid svg[id^="mermaid"] .node text,
.mermaid svg[id^="mermaid"] .node foreignObject div,
.mermaid svg[id^="mermaid"] .node foreignObject span,
.mermaid svg[id^="mermaid"] .node foreignObject p,
svg[id^="mermaid"] .node .label,
svg[id^="mermaid"] .nodeLabel,
svg[id^="mermaid"] .node text,
svg[id^="mermaid"] .node foreignObject div,
svg[id^="mermaid"] .node foreignObject span,
svg[id^="mermaid"] .node foreignObject p {
color: #ffffff !important;
fill: #ffffff !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.9375rem !important;
}
/* Edge/arrow lines - gradient end color (#009296 teal) */
.mermaid svg[id^="mermaid"] .edgePath .path,
.mermaid svg[id^="mermaid"] .flowchart-link,
svg[id^="mermaid"] .edgePath .path,
svg[id^="mermaid"] .flowchart-link {
stroke: #009296 !important;
}
/* Arrow heads - gradient end color */
.mermaid svg[id^="mermaid"] .marker path,
.mermaid svg[id^="mermaid"] .arrowheadPath,
.mermaid svg[id^="mermaid"] marker path,
svg[id^="mermaid"] .marker path,
svg[id^="mermaid"] .arrowheadPath,
svg[id^="mermaid"] marker path {
fill: #009296 !important;
stroke: #009296 !important;
}
/* Edge labels - transparent background, Inter font */
.mermaid svg[id^="mermaid"] .edgeLabel,
.mermaid svg[id^="mermaid"] .edgeLabel rect,
.mermaid svg[id^="mermaid"] .edgeLabel span,
.mermaid svg[id^="mermaid"] .labelBkg,
svg[id^="mermaid"] .edgeLabel,
svg[id^="mermaid"] .edgeLabel rect,
svg[id^="mermaid"] .edgeLabel span,
svg[id^="mermaid"] .labelBkg {
background: transparent !important;
background-color: transparent !important;
fill: transparent !important;
}
.mermaid svg[id^="mermaid"] .edgeLabel text,
.mermaid svg[id^="mermaid"] .edgeLabel span,
.mermaid svg[id^="mermaid"] .edgeLabel p,
svg[id^="mermaid"] .edgeLabel text,
svg[id^="mermaid"] .edgeLabel span,
svg[id^="mermaid"] .edgeLabel p {
color: var(--ifm-font-color-base) !important;
fill: var(--ifm-font-color-base) !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.875rem !important;
}
/* Cluster/subgraph boxes - gradient end color border (#009296) */
.mermaid svg[id^="mermaid"] .cluster rect,
svg[id^="mermaid"] .cluster rect {
fill: rgba(0, 146, 150, 0.08) !important;
stroke: #009296 !important;
stroke-width: 2px !important;
}
/* Cluster labels - Inter font */
.mermaid svg[id^="mermaid"] .cluster text,
.mermaid svg[id^="mermaid"] .cluster .nodeLabel,
.mermaid svg[id^="mermaid"] .cluster-label text,
.mermaid svg[id^="mermaid"] .cluster-label span,
.mermaid svg[id^="mermaid"] .cluster-label p,
svg[id^="mermaid"] .cluster text,
svg[id^="mermaid"] .cluster .nodeLabel,
svg[id^="mermaid"] .cluster-label text,
svg[id^="mermaid"] .cluster-label span,
svg[id^="mermaid"] .cluster-label p {
color: var(--ifm-font-color-base) !important;
fill: var(--ifm-font-color-base) !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.9375rem !important;
}
/* All mermaid text should use Inter */
.mermaid svg[id^="mermaid"] text,
.mermaid svg[id^="mermaid"] span,
.mermaid svg[id^="mermaid"] p,
svg[id^="mermaid"] text,
svg[id^="mermaid"] span,
svg[id^="mermaid"] p {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
}
/* Dark mode adjustments */
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel span,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .edgeLabel p,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel text,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel span,
[data-theme='dark'] svg[id^="mermaid"] .edgeLabel p {
color: #e2e8f0 !important;
fill: #e2e8f0 !important;
}
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label text,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label span,
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster-label p,
[data-theme='dark'] svg[id^="mermaid"] .cluster text,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label text,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label span,
[data-theme='dark'] svg[id^="mermaid"] .cluster-label p {
color: #e2e8f0 !important;
fill: #e2e8f0 !important;
}
[data-theme='dark'] .mermaid svg[id^="mermaid"] .cluster rect,
[data-theme='dark'] svg[id^="mermaid"] .cluster rect {
fill: rgba(0, 146, 150, 0.15) !important;
}
/* List styling */
article ul, article ol {
font-size: 0.9375rem;
@@ -1,7 +0,0 @@
import ComponentTypes from '@theme-original/NavbarItem/ComponentTypes';
import NavbarIconLink from '@site/src/components/NavbarIconLink';
export default {
...ComponentTypes,
'custom-iconLink': NavbarIconLink,
};

Some files were not shown because too many files have changed in this diff Show More