Compare commits

...
66 Commits
Author SHA1 Message Date
DK09876 39df7eab99 make transformation less specific 2025-12-15 22:11:53 -06:00
DK09876 68957c428f Make the transformation deterministic 2025-12-15 20:50:47 -06:00
DK09876 034d604e72 Fix module import error 2025-12-15 20:09:45 -06:00
DK09876 308e79551a Add dependency step 2025-12-15 19:51:53 -06:00
DK09876 92fde4abf2 parallelize 2025-12-15 19:29:53 -06:00
DK09876 32bd8796e9 Fix root repo issue 2025-12-15 19:16:29 -06:00
DK09876 948d8291a0 remake test 2025-12-15 19:09:34 -06:00
DK09876 de943e88f2 Improve pre filter 2025-12-15 17:56:38 -06:00
DK09876 64baad5e6c Remake of test 2025-12-15 17:17:34 -06:00
DK09876 05562d3472 Get rid of temperature for reasoning models 2025-12-15 16:47:35 -06:00
DK09876 ef483e39a2 Use smarter model for test 2025-12-15 16:39:42 -06:00
DK09876 48483221ee Verified LLM commands 2025-12-15 16:20:52 -06:00
DK09876 8d8a2453c8 Fix file based tests 2025-12-15 15:55:16 -06:00
DK09876 a7aae18721 Skip pytest samples that are already covered 2025-12-15 15:31:37 -06:00
DK09876 06f71f869d Fix some issues seen with the test and async 2025-12-15 15:03:17 -06:00
DK09876 16e5bcfbea Add dependency discover step in the test 2025-12-15 14:19:16 -06:00
DK09876 1ebb182fa0 Create an actionable summary for the test failures 2025-12-15 13:49:49 -06:00
DK09876 70df6d313c Summarize the failed tests using an LLM call 2025-12-15 13:15:54 -06:00
DK09876 f413175799 Properly emit the summary 2025-12-15 12:59:48 -06:00
DK09876 4ce7af0cd4 directory test fixes 2025-12-15 12:21:52 -06:00
DK09876 d13bb728f8 Create summary of test results 2025-12-15 12:00:55 -06:00
DK09876 bca8dd7c94 Tell the test to install all required dependencies 2025-12-15 11:53:23 -06:00
DK09876 69b1af26ac Run tests in parallel 2025-12-15 11:31:22 -06:00
DK09876 e7ccf0b70c More dependency fixes 2025-12-15 11:09:19 -06:00
DK09876 0b044845f2 Follow patterns from other CI 2025-12-15 10:59:43 -06:00
DK09876 787449620b UV fix 2025-12-15 10:55:01 -06:00
DK09876 a32949a342 Add test to CI testing all code samples 2025-12-15 10:46:54 -06:00
Nicolò Boschi 1cef364719 enable model tests on ci (#29) 2025-12-15 15:18:09 +01:00
Nicolò Boschi dff293ca8c fix doc link styling 2025-12-15 14:54:56 +01:00
Nicolò Boschi f4bc8443b3 changelog generator 2025-12-15 14:46:14 +01:00
Nicolò Boschi ae26a8603b models doc 2025-12-15 11:34:34 +01:00
Nicolò Boschi 183b9dacb4 Release v0.1.5
- Update version to 0.1.5 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-15 10:48:53 +01:00
Nicolò Boschi 8a7c6e4e91 litellm release integration 2025-12-15 10:48:27 +01:00
DK09876andClaude Opus 4.5 dfccbf29f1 Added hindsight_liteLLM implementation (#17)
* Added hindsight_liteLLM implementation

* Add instructions for entity vs bank id

* Add another line about entity

* Address PR review comments and enhance litellm integration

- Remove deprecated limit parameter from recall() and arecall() functions
  since Hindsight uses budget/max_tokens for result control
- Remove dead MODEL_MAX_OUTPUT_TOKENS dict and max_output_tokens property
  from LLMProvider (superseded by hardcoded max_completion_tokens)
- Add test-litellm-integration job to CI workflow
- Add reflect API support with use_reflect config option
- Add verbose mode debug info via get_last_injection_debug()
- Add entity_id support for multi-user memory isolation
- Add retain() and reflect() wrapper functions
- Update docstrings and examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Make max_memories optional to allow unlimited memory injection

- Change max_memories default from 10 to None (no limit)
- When max_memories is None, all results from the API are used
- Fix recall result handling to properly detect list vs object return
- Update wrappers (OpenAI, Anthropic) with same optional behavior

This allows users to control memory limits via max_memory_tokens
and recall_budget without an artificial count limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Remove entity_id from hindsight_litellm; add gpt-4o token cap

Multi-user support now uses separate bank_ids per user instead of
entity_id scoping (e.g., bank_id=f"user-{user_id}"). This simplifies
the API and aligns with the Hindsight architecture.

Also fixes max_completion_tokens error for gpt-4o models by capping
the value at 16384 (gpt-4o's limit) instead of sending the default
65000 which exceeds the model's supported maximum.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Fix dark mode styling across Control Plane UI components

Improvements to ensure proper text visibility and contrast in both light
and dark modes:

- Add global CSS rules for datetime-local calendar picker icon visibility
  using filter: invert() for both light (0.5) and dark (1) modes
- Fix text colors in dialog components to use theme-aware foreground colors
- Update memory detail panel, document/chunk modals, and data views to use
  proper dark mode text classes (text-foreground, text-card-foreground)
- Fix form labels, headings, and content text in bank selector dialogs
- Update entities view and documents view table styling for dark mode
- Bump package versions to 0.1.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Remove session_id feature and add How It Works section to README

- Remove session_id and session management (new_session, set_session,
  get_session) from config.py, callbacks.py, and __init__.py
- Session management was a client-only abstraction not backed by core API
- Add "How It Works" section to README with visual flow diagram
- Update README to remove session management documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* Fix readme example

* Add dark mode again

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2025-12-15 10:42:19 +01:00
Chris Latimer dfea4dbe15 Trademark to README 2025-12-14 18:58:34 -07:00
Derek Bouius fcea8afa6c Change npm packaging structure and fix contributing info (#16)
* change the package to workspace concept

* add provider name and change default model

* add the node_modules to git ignore

* change the npm runs to use workspace

* fix the start scripts to use the workspace

* update the uv.lock

* updated instructions

* update the docker build to use the npm workspace

* Update package-lock.json after merge to sync workspace dependencies

* fix merge conflict
2025-12-12 14:14:19 -05:00
Nicolò Boschi 94c2b85c81 switch to pg0-embedded (#28)
* switch to pg0-embedded

* switch to pg0-embedded

* stricter mcp lib
2025-12-12 19:13:26 +01:00
Chris Bartholomew 160c5581ec fix: add DOM.Iterable lib to resolve URLSearchParams.entries() type error (#27)
The generated queryKeySerializer.gen.ts uses URLSearchParams.entries() which
requires DOM.Iterable in the TypeScript lib config for proper type definitions.
2025-12-12 17:34:47 +01:00
Nicolò Boschi 70983f5817 fix 400 retries on llm 2025-12-12 17:15:56 +01:00
Chris Latimer 44e9571572 README banner 2025-12-12 09:03:59 -07:00
Nicolò Boschi 7445cef7b7 feat: add optional graph retriever MPFP (#26)
* feat: add optional graph retriever MPFP

* feat: add optional graph retriever MPFP
2025-12-12 16:58:50 +01:00
Derek Bouius f018cc5677 fix: upgrade Next.js to 16.0.10 to patch CVE-2025-55184 and CVE-2025-55183 (#25)
CVE-2025-55184 (High) - Denial of Service via malicious HTTP request
CVE-2025-55183 (Medium) - Source Code Exposure of Server Actions

Reference: https://vercel.com/kb/bulletin/security-bulletin-cve-2025-55184-and-cve-2025-55183
2025-12-12 16:43:26 +01:00
Nicolò Boschi 922164e25c fix recall trace visualization 2025-12-12 14:38:37 +01:00
Derek Bouius d6b7b9b398 Fix base CI issues and the defaults in .env.example (#24)
* Add the LLM_PROVIDER in example

* fix the assert in testing recall

* 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.

* lock the sentence transformer packages to align with the breaking changes around lazy tensor loading

* Add the LLM_PROVIDER in example

* fix the assert in testing recall

* trial to fix failing client tests

* pre-cache the model so CI doesn't need workarounds

* 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.

* add debug to figure out why docker build fails sometimes

* use the CPU only version of pytorch to avoid pulling cuda libraries

* add best match strategy to uv

* change the example openai model
2025-12-11 16:48:12 -05:00
Nicolò Boschi 158a6aac9a fix cli installer 2025-12-11 16:26:05 +01:00
Nicolò Boschi 38e73a1414 fix cli installer 2025-12-11 16:22:04 +01: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
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
Nicolò Boschi 52826de55d improve llms.txt 2025-12-10 13:55:55 +01:00
Nicolò Boschi 0000c54509 add llms.txt 2025-12-10 13:52:38 +01:00
Nicolò Boschi e677a018d7 add llms.txt 2025-12-10 13:52:32 +01:00
Nicolò Boschi 4191597098 add llms.txt 2025-12-10 13:51:21 +01:00
Nicolò Boschi e722a48b14 add tei support 2025-12-10 12:12:21 +01:00
Nicolò Boschi f7789f4961 fix openapi tags 2025-12-10 10:15:13 +01:00
223 changed files with 34479 additions and 25776 deletions
+18 -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)
@@ -13,3 +14,19 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_RERANKER_PROVIDER=local
# For local provider:
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
-11
View File
@@ -1,11 +0,0 @@
name: 'Setup pg0'
description: 'Install pg0 embedded PostgreSQL'
runs:
using: 'composite'
steps:
- name: Install pg0
shell: bash
run: |
curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash
echo "$HOME/.pg0/bin" >> $GITHUB_PATH
+3 -6
View File
@@ -20,18 +20,15 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: hindsight-docs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: hindsight-docs/package-lock.json
- run: npm ci
- run: npm run build
cache-dependency-path: package-lock.json
- run: npm ci --workspace=hindsight-docs
- run: npm run build --workspace=hindsight-docs
- uses: actions/upload-pages-artifact@v3
with:
path: hindsight-docs/build
+26 -48
View File
@@ -38,6 +38,10 @@ jobs:
working-directory: ./hindsight
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
# Publish in order (client and api first, then hindsight-all which depends on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -57,6 +61,12 @@ jobs:
packages-dir: ./hindsight/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -66,6 +76,7 @@ jobs:
hindsight-clients/python/dist/*
hindsight-api/dist/*
hindsight/dist/*
hindsight-integrations/litellm/dist/*
retention-days: 1
release-typescript-client:
@@ -80,14 +91,14 @@ jobs:
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
run: npm ci --workspace=hindsight-clients/typescript
- name: Build
working-directory: ./hindsight-clients/typescript
run: npm run build
run: npm run build --workspace=hindsight-clients/typescript
- name: Publish to npm
working-directory: ./hindsight-clients/typescript
@@ -219,6 +230,9 @@ jobs:
release-helm-chart:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
@@ -228,12 +242,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:
@@ -297,6 +317,7 @@ jobs:
cp artifacts/python-packages/hindsight-clients/python/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# Rust CLI binaries
@@ -307,54 +328,11 @@ jobs:
cp artifacts/helm-chart/*.tgz release-assets/ || true
ls -la release-assets/
- name: Generate release notes
run: |
cat << 'EOF' > release-notes.md
## Quick Start
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
# Start the server
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}
```
## Docker Images
- `ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}` - Standalone (recommended)
- `ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}` - API only
- `ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}` - Web UI only
## CLI
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
```
## Python
```bash
pip install hindsight-all # or hindsight-api, hindsight-client
```
## TypeScript/JavaScript
```bash
npm install @vectorize-io/hindsight-client
```
## Helm
```bash
helm install hindsight oci://ghcr.io/${{ github.repository_owner }}/charts/hindsight --version ${{ steps.get_version.outputs.VERSION }}
```
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
body_path: release-notes.md
generate_release_notes: true
draft: false
prerelease: false
env:
+278 -21
View File
@@ -9,6 +9,54 @@ concurrency:
cancel-in-progress: true
jobs:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: hindsight-all
path: hindsight
- name: hindsight-api
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build ${{ matrix.name }}
working-directory: ./${{ matrix.path }}
run: uv build
build-typescript-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-clients/typescript
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-docs:
runs-on: ubuntu-latest
@@ -19,14 +67,14 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
working-directory: ./hindsight-docs
run: npm ci
run: npm ci --workspace=hindsight-docs
- name: Build docs
working-directory: ./hindsight-docs
run: npm run build
run: npm run build --workspace=hindsight-docs
build-rust-cli:
runs-on: ubuntu-latest
@@ -106,8 +154,13 @@ jobs:
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_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
@@ -123,16 +176,33 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- 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 +216,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
@@ -161,9 +233,6 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
@@ -174,11 +243,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 +292,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
@@ -243,16 +314,13 @@ jobs:
with:
node-version: '20'
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- 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 +373,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
@@ -332,16 +402,13 @@ jobs:
hindsight-clients/rust/target
key: ${{ runner.os }}-cargo-client-${{ hashFiles('hindsight-clients/rust/Cargo.lock') }}
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- 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: |
@@ -377,3 +444,193 @@ jobs:
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-litellm-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build litellm integration
working-directory: ./hindsight-integrations/litellm
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/litellm
run: uv sync --extra dev
- name: Run tests
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
test-doc-examples:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Model for test generation and analysis (options: gpt-4o, o3-mini, o1, etc.)
DOC_TEST_MODEL: o3-mini
# 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
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Build Python client
working-directory: ./hindsight-clients/python
run: uv build
- name: Install Python client
working-directory: ./hindsight-clients/python
run: uv sync --index-strategy unsafe-best-match
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Install test dependencies in API venv
working-directory: ./hindsight-api
run: |
uv pip install ../hindsight-clients/python requests anthropic
uv pip install ../hindsight-integrations/litellm
uv pip install ../hindsight-integrations/openai
- name: Verify Python dependencies
working-directory: ./hindsight-api
run: |
echo "=== Verifying Python dependencies ==="
uv run python -c "
import sys
print(f'Python: {sys.executable}')
print(f'Prefix: {sys.prefix}')
# Check required packages
packages = [
'hindsight_client',
'hindsight_litellm',
'hindsight_openai',
'anthropic',
'openai',
]
missing = []
for pkg in packages:
try:
__import__(pkg)
print(f' ✓ {pkg}')
except ImportError as e:
print(f' ✗ {pkg}: {e}')
missing.append(pkg)
if missing:
print(f'\nERROR: Missing packages: {missing}')
sys.exit(1)
print('\nAll Python dependencies verified!')
"
- name: Install TypeScript client dependencies
run: npm ci
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
- name: Install TypeScript client globally
working-directory: ./hindsight-clients/typescript
run: npm install -g .
- name: Make TypeScript client available for temp files
run: |
# ESM modules don't use NODE_PATH, so create node_modules in /tmp
# where test scripts are written
mkdir -p /tmp/node_modules/@vectorize-io
ln -s ${{ github.workspace }}/hindsight-clients/typescript /tmp/node_modules/@vectorize-io/hindsight-client
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build and install hindsight CLI
working-directory: ./hindsight-cli
run: |
cargo build --release
sudo cp target/release/hindsight /usr/local/bin/
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Test documentation examples
working-directory: ./hindsight-api
env:
REPO_ROOT: ${{ github.workspace }}
run: uv run python ../scripts/test-doc-examples.py
- name: Write test summary
if: always()
run: |
echo "=== Documentation Test Summary ==="
cat /tmp/doc-test-summary.md
cat /tmp/doc-test-summary.md >> $GITHUB_STEP_SUMMARY
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
+3
View File
@@ -9,6 +9,9 @@ wheels/
# Virtual environments
.venv
# Node
node_modules/
# Environment variables
.env
+151
View File
@@ -0,0 +1,151 @@
# AGENTS.md
This document captures architectural decisions and coding conventions for the Hindsight project.
## Documentation
- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/)
- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/)
- **API reference**: Auto-generated from OpenAPI spec
## Project Structure
```
hindsight/ # Python package for embedded usage
hindsight-api/ # FastAPI server (core memory engine)
hindsight-cli/ # Rust CLI client
hindsight-control-plane/ # Next.js admin UI
hindsight-docs/ # Docusaurus documentation site
hindsight-dev/ # Development tools and benchmarks
hindsight-integrations/ # Framework integrations (LangChain, etc.)
hindsight-clients/ # Generated API clients (Python, TypeScript, Rust)
```
## Core Concepts
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
- Banks contain: memory units (facts), entities, documents, entity links
- Banks have a **disposition** (personality traits) and **background** (context)
- Bank isolation is strict - no cross-bank data leakage
### Memory Types
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
### Operations
- **Retain**: Store new memories (extracts facts, entities, relationships)
- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search)
- **Reflect**: Deep analysis to form new insights/opinions
## API Design Decisions
### Single Bank Per Request
- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank
- Multi-bank queries are the **client/agent's responsibility** to orchestrate
- This keeps the API simple and the isolation model clear
### Disposition Traits (3-trait system)
- **Skepticism** (1-5): How skeptical vs trusting when forming opinions
- **Literalism** (1-5): How literally to interpret information
- **Empathy** (1-5): How much to consider emotional context
- These influence the `reflect` operation, not `recall`
- Background info also only affects `reflect` (opinion formation)
## Multi-Bank Architecture Patterns
See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides:
- **Per-User Memory**: One bank per user, simplest pattern
- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates
## Developer Guide
### Running the API Server
```bash
# From project root
./scripts/dev/start-api.sh
# With options
./scripts/dev/start-api.sh --reload --port 8888 --log-level debug
```
### Running Tests
```bash
# API tests
cd hindsight-api
uv run pytest tests/
# Specific test
uv run pytest tests/test_http_api_integration.py -v
```
### Generating OpenAPI Spec
After changing API endpoints, regenerate the OpenAPI spec and docs:
```bash
./scripts/generate-openapi.sh
```
This will:
1. Generate `openapi.json` at project root
2. Copy to `hindsight-docs/openapi.json`
3. Regenerate API reference documentation
### Generating API Clients
After updating the OpenAPI spec, regenerate all clients:
```bash
./scripts/generate-clients.sh
```
This generates:
- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs)
- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker)
- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts)
Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration.
### Running the Documentation Site
```bash
./scripts/dev/start-docs.sh
```
### Running the Control Plane
```bash
./scripts/dev/start-control-plane.sh
```
## Code Style
### Python (hindsight-api)
- Use `uv` for package management
- Async throughout (asyncpg, async FastAPI endpoints)
- Pydantic models for request/response validation
- No py files at project root - maintain clean directory structure
### TypeScript (control-plane, clients)
- Next.js with App Router for control plane
- Tailwind CSS with shadcn/ui components
### Rust (CLI)
- Async with tokio
- reqwest for HTTP client
- progenitor for API client generation
## Database
- 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
+14 -4
View File
@@ -5,13 +5,23 @@ Thanks for your interest in contributing to Hindsight!
## Getting Started
1. Fork and clone the repository
2. Install dependencies:
```bash
cd hindsight-api && uv sync
git clone [email protected]:vectorize-io/hindsight.git
cd hindsight
```
3. Set up your environment:
2. Set up your environment:
```bash
export OPENAI_API_KEY=your-key
cp .env.example .env
```
Edit the .env to add LLM API key and config as required
3. Install dependencies:
```bash
# Python dependencies
uv sync --directory hindsight-api/
# Node dependencies (uses npm workspaces)
npm install
```
## Development
-253
View File
@@ -1,253 +0,0 @@
<div align="center">
# Hindsight
**Agent Memory that Works Like Human Memory**
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![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)
[![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>
---
## What is Hindsight?
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
## How Hindsight Works
![Overview](./hindsight-docs/static/img/hindsight-overview.png)
Hindsight organizes memory into four networks to mimic the way human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
Memories in Hindsight are stored in banks (e.g. memory banks). When memories are retained, they are transformed to construct a series of search indexes, time series data, and entity/relationship graphs.
---
## Quick Start
### Docker (recommended)
```bash
export OPENAI_API_KEY=your-key
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
```
API: http://localhost:8888
UI: http://localhost:9999
Install client:
```bash
pip install hindsight-client
# or
npm install @vectorize-io/hindsight-client
```
Python example:
```python
from hindsight import HindsightClient
client = HindsightClient(base_url="http://localhost:8888")
# Store
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
# Query
results = client.recall(bank_id="my-agent", query="What does Alice do?")
# Reflect
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
print(response.text)
```
### Python (embedded, no Docker)
```bash
pip install hindsight-all
```
```python
import os
from hindsight import HindsightServer, HindsightClient
with HindsightServer(
llm_provider="openai",
llm_model="gpt-4o-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?")
```
### TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```typescript
import { HindsightClient } from '@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?');
```
---
## Architecture & Operations
### Retain
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
# With context and timestamp
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2025-06-15T10:00:00Z"
)
```
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)
### Recall
The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.recall(bank_id="my-bank", query="What does Alice do?")
# Temporal
results = client.recall(bank_id="my-bank", query="What happened in June?")
```
Recall performs 4 retrieval strategies in parallel:
- Semantic: Vector similarity
- Keyword: BM25 exact matching
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Retain Operation](hindsight-docs/static/img/recall-operation.png)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
The final output is trimmed as needed to fit within the token limit.
### Reflect
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
For example, the `reflect` operation can be used to support use cases such as:
- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.
The `reflect` operation can also be used to handle on-demand question answering or analysis which require more deep thinking.
```python
from hindsight_client import Hindsight
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)
---
## Resources
**Documentation:** [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
**Clients:**
- [Python](http://hindsight.vectorize.io/sdks/python)
- [Node.js](http://hindsight.vectorize.io/sdks/nodejs)
- [REST API](http://hindsight.vectorize.io/api-reference)
**Community:**
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
## License
MIT — see [LICENSE](./LICENSE)
---
Built by [Vectorize.io](https://vectorize.io)
+37 -52
View File
@@ -1,16 +1,16 @@
<div align="center">
# Hindsight
![Hindsight Banner](./hindsight-docs/static/img/banner.svg)
**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>
@@ -18,7 +18,7 @@
## What is Hindsight?
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
@@ -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)
+27 -62
View File
@@ -40,9 +40,11 @@ WORKDIR /app/api
# Sync dependencies (will create lock file if needed)
RUN uv sync
# Copy source code and alembic migrations
# Copy source code (alembic migrations are inside hindsight_api/)
COPY hindsight-api/hindsight_api ./hindsight_api
COPY hindsight-api/alembic ./alembic
# 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)
@@ -52,13 +54,15 @@ FROM node:20-slim AS sdk-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping SDK build" && exit 0; fi
WORKDIR /app/sdk
WORKDIR /app
COPY hindsight-clients/typescript/package*.json ./
RUN npm ci
# Copy root package files for npm workspaces
COPY package.json package-lock.json ./
COPY hindsight-clients/typescript/ ./hindsight-clients/typescript/
COPY hindsight-clients/typescript/ ./
RUN npm run build
# Install and build SDK using workspace
RUN npm ci -w @vectorize-io/hindsight-client
RUN npm run build -w @vectorize-io/hindsight-client
# =============================================================================
# Stage: Control Plane Builder
@@ -71,7 +75,7 @@ RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Install Control Plane dependencies
# Only copy package.json (not package-lock.json) to ensure npm installs
@@ -128,34 +132,10 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
USER hindsight
# Set PATH for hindsight user
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
ENV PATH="/app/api/.venv/bin:${PATH}"
# Install pg0 binary
RUN mkdir -p /home/hindsight/.hindsight/bin && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
PG0_BINARY="pg0-linux-aarch64-gnu"; \
elif [ "$ARCH" = "x86_64" ]; then \
PG0_BINARY="pg0-linux-x86_64-gnu"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
echo "Installing pg0 binary: $PG0_BINARY" && \
for i in 1 2 3 4 5; do \
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 && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
/home/hindsight/.hindsight/bin/pg0 --version
# Pre-download PostgreSQL binaries
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
ENV PG0_HOME=/home/hindsight/.pg0-cache
RUN pg0 start --help && \
(pg0 start --name hindsight --port 5555 --username hindsight --password hindsight --database hindsight && \
sleep 2 && \
pg0 stop --name hindsight && \
echo "PostgreSQL pre-cached to $PG0_HOME") || echo "Pre-download skipped"
ENV PG0_HOME=/home/hindsight/.pg0
@@ -175,6 +155,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"]
@@ -186,7 +167,7 @@ FROM node:20-alpine AS cp-only
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
@@ -239,7 +220,7 @@ RUN useradd -m -s /bin/bash hindsight
COPY --from=api-builder /app/api /app/api
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
@@ -260,34 +241,17 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
USER hindsight
# Set PATH for hindsight user
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
ENV PATH="/app/api/.venv/bin:${PATH}"
# Install pg0 binary
RUN mkdir -p /home/hindsight/.hindsight/bin && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
PG0_BINARY="pg0-linux-aarch64-gnu"; \
elif [ "$ARCH" = "x86_64" ]; then \
PG0_BINARY="pg0-linux-x86_64-gnu"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
echo "Installing pg0 binary: $PG0_BINARY" && \
for i in 1 2 3 4 5; do \
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 && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
/home/hindsight/.hindsight/bin/pg0 --version
# Pre-download PostgreSQL binaries
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
ENV PG0_HOME=/home/hindsight/.pg0-cache
RUN pg0 start --help && \
(pg0 start --name hindsight --port 5555 --username hindsight --password hindsight --database hindsight && \
sleep 2 && \
pg0 stop --name hindsight && \
echo "PostgreSQL pre-cached to $PG0_HOME") || echo "Pre-download skipped"
RUN /app/api/.venv/bin/python -c "\
from pg0 import Pg0; \
print('Pre-caching PostgreSQL binaries...'); \
pg = Pg0(name='hindsight', port=5555, username='hindsight', password='hindsight', database='hindsight'); \
pg.start(); \
pg.stop(); \
print('PostgreSQL pre-cached to PG0_HOME')" || echo "Pre-download skipped"
ENV PG0_HOME=/home/hindsight/.pg0
@@ -309,6 +273,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"]
+3 -8
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}"
@@ -26,21 +23,19 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
python -m hindsight_api.web.server 2>&1 | sed -u 's/^/[api] /' &
hindsight-api 2>&1 | sed -u 's/^/[api] /' &
API_PID=$!
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.5
appVersion: "0.1.5"
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
+10 -2
View File
@@ -16,11 +16,15 @@ from .engine.search.trace import (
SearchPhaseMetrics,
)
from .engine.search.tracer import SearchTracer
from .engine.embeddings import Embeddings, SentenceTransformersEmbeddings
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.llm_wrapper import LLMConfig
from .config import HindsightConfig, get_config
__all__ = [
"MemoryEngine",
"HindsightConfig",
"get_config",
"SearchTrace",
"SearchTracer",
"QueryInfo",
@@ -32,7 +36,11 @@ __all__ = [
"SearchSummary",
"SearchPhaseMetrics",
"Embeddings",
"SentenceTransformersEmbeddings",
"LocalSTEmbeddings",
"RemoteTEIEmbeddings",
"CrossEncoderModel",
"LocalSTCrossEncoder",
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.1.0"
+114 -87
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:
@@ -729,9 +733,11 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
await memory.close()
logging.info("Memory system closed")
from hindsight_api import __version__
app = FastAPI(
title="Hindsight HTTP API",
version="1.0.0",
version=__version__,
description="HTTP API for Hindsight",
contact={
"name": "Memory System",
@@ -793,7 +799,8 @@ def _register_routes(app: FastAPI):
response_model=GraphDataResponse,
summary="Get memory graph data",
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.",
operation_id="get_graph"
operation_id="get_graph",
tags=["Memory"]
)
async def api_graph(bank_id: str,
type: Optional[str] = None
@@ -814,7 +821,8 @@ def _register_routes(app: FastAPI):
response_model=ListMemoryUnitsResponse,
summary="List memory units",
description="List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
operation_id="list_memories"
operation_id="list_memories",
tags=["Memory"]
)
async def api_list(bank_id: str,
type: Optional[str] = None,
@@ -855,17 +863,14 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories/recall",
response_model=RecallResponse,
summary="Recall memory",
description="""
Recall memory using semantic similarity and spreading activation.
The type parameter is optional and must be one of:
- 'world': General knowledge about people, places, events, and things that happen
- 'experience': Memories about experience, conversations, actions taken, and tasks performed
- 'opinion': The bank's formed beliefs, perspectives, and viewpoints
Set include_entities=true to get entity observations alongside recall results.
""",
operation_id="recall_memories"
description="Recall memory using semantic similarity and spreading activation.\n\n"
"The type parameter is optional and must be one of:\n"
"- `world`: General knowledge about people, places, events, and things that happen\n"
"- `experience`: Memories about experience, conversations, actions taken, and tasks performed\n"
"- `opinion`: The bank's formed beliefs, perspectives, and viewpoints\n\n"
"Set `include_entities=true` to get entity observations alongside recall results.",
operation_id="recall_memories",
tags=["Memory"]
)
async def api_recall(bank_id: str, request: RecallRequest):
"""Run a recall and return results with trace."""
@@ -972,18 +977,16 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/reflect",
response_model=ReflectResponse,
summary="Reflect and generate answer",
description="""
Reflect and formulate an answer using bank identity, world facts, and opinions.
This endpoint:
1. Retrieves experience (conversations and events)
2. Retrieves world facts relevant to the query
3. Retrieves existing opinions (bank's perspectives)
4. Uses LLM to formulate a contextual answer
5. Extracts and stores any new opinions formed
6. Returns plain text answer, the facts used, and new opinions
""",
operation_id="reflect"
description="Reflect and formulate an answer using bank identity, world facts, and opinions.\n\n"
"This endpoint:\n"
"1. Retrieves experience (conversations and events)\n"
"2. Retrieves world facts relevant to the query\n"
"3. Retrieves existing opinions (bank's perspectives)\n"
"4. Uses LLM to formulate a contextual answer\n"
"5. Extracts and stores any new opinions formed\n"
"6. Returns plain text answer, the facts used, and new opinions",
operation_id="reflect",
tags=["Memory"]
)
async def api_reflect(bank_id: str, request: ReflectRequest):
metrics = get_metrics_collector()
@@ -1029,7 +1032,8 @@ def _register_routes(app: FastAPI):
response_model=BankListResponse,
summary="List all memory banks",
description="Get a list of all agents with their profiles",
operation_id="list_banks"
operation_id="list_banks",
tags=["Banks"]
)
async def api_list_banks():
"""Get list of all banks with their profiles."""
@@ -1046,7 +1050,8 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/stats",
summary="Get statistics for memory bank",
description="Get statistics about nodes and links for a specific agent",
operation_id="get_agent_stats"
operation_id="get_agent_stats",
tags=["Banks"]
)
async def api_stats(bank_id: str):
"""Get statistics about memory nodes and links for a memory bank."""
@@ -1167,7 +1172,8 @@ def _register_routes(app: FastAPI):
response_model=EntityListResponse,
summary="List entities",
description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
operation_id="list_entities"
operation_id="list_entities",
tags=["Entities"]
)
async def api_list_entities(bank_id: str,
limit: int = Query(default=100, description="Maximum number of entities to return")
@@ -1189,7 +1195,8 @@ def _register_routes(app: FastAPI):
response_model=EntityDetailResponse,
summary="Get entity details",
description="Get detailed information about an entity including observations (mental model).",
operation_id="get_entity"
operation_id="get_entity",
tags=["Entities"]
)
async def api_get_entity(bank_id: str, entity_id: str):
"""Get entity details with observations."""
@@ -1239,7 +1246,8 @@ def _register_routes(app: FastAPI):
response_model=EntityDetailResponse,
summary="Regenerate entity observations",
description="Regenerate observations for an entity based on all facts mentioning it.",
operation_id="regenerate_entity_observations"
operation_id="regenerate_entity_observations",
tags=["Entities"]
)
async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
"""Regenerate observations for an entity."""
@@ -1296,7 +1304,8 @@ def _register_routes(app: FastAPI):
response_model=ListDocumentsResponse,
summary="List documents",
description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.",
operation_id="list_documents"
operation_id="list_documents",
tags=["Documents"]
)
async def api_list_documents(bank_id: str,
q: Optional[str] = None,
@@ -1332,7 +1341,8 @@ def _register_routes(app: FastAPI):
response_model=DocumentResponse,
summary="Get document details",
description="Get a specific document including its original text",
operation_id="get_document"
operation_id="get_document",
tags=["Documents"]
)
async def api_get_document(bank_id: str,
document_id: str
@@ -1363,7 +1373,8 @@ def _register_routes(app: FastAPI):
response_model=ChunkResponse,
summary="Get chunk details",
description="Get a specific chunk by its ID",
operation_id="get_chunk"
operation_id="get_chunk",
tags=["Documents"]
)
async def api_get_chunk(chunk_id: str):
"""
@@ -1389,17 +1400,14 @@ def _register_routes(app: FastAPI):
@app.delete(
"/v1/default/banks/{bank_id}/documents/{document_id}",
summary="Delete a document",
description="""
Delete a document and all its associated memory units and links.
This will cascade delete:
- The document itself
- All memory units extracted from this document
- All links (temporal, semantic, entity) associated with those memory units
This operation cannot be undone.
""",
operation_id="delete_document"
description="Delete a document and all its associated memory units and links.\n\n"
"This will cascade delete:\n"
"- The document itself\n"
"- All memory units extracted from this document\n"
"- All links (temporal, semantic, entity) associated with those memory units\n\n"
"This operation cannot be undone.",
operation_id="delete_document",
tags=["Documents"]
)
async def api_delete_document(bank_id: str,
document_id: str
@@ -1436,7 +1444,8 @@ This operation cannot be undone.
"/v1/default/banks/{bank_id}/operations",
summary="List async operations",
description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
operation_id="list_operations"
operation_id="list_operations",
tags=["Operations"]
)
async def api_list_operations(bank_id: str):
"""List all async operations (pending and failed) for a memory bank."""
@@ -1480,7 +1489,8 @@ This operation cannot be undone.
"/v1/default/banks/{bank_id}/operations/{operation_id}",
summary="Cancel a pending async operation",
description="Cancel a pending async operation by removing it from the queue",
operation_id="cancel_operation"
operation_id="cancel_operation",
tags=["Operations"]
)
async def api_cancel_operation(bank_id: str, operation_id: str):
"""Cancel a pending async operation."""
@@ -1530,7 +1540,8 @@ This operation cannot be undone.
response_model=BankProfileResponse,
summary="Get memory bank profile",
description="Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
operation_id="get_bank_profile"
operation_id="get_bank_profile",
tags=["Banks"]
)
async def api_get_bank_profile(bank_id: str):
"""Get memory bank profile (disposition + background)."""
@@ -1556,7 +1567,8 @@ This operation cannot be undone.
response_model=BankProfileResponse,
summary="Update memory bank disposition",
description="Update bank's disposition traits (skepticism, literalism, empathy)",
operation_id="update_bank_disposition"
operation_id="update_bank_disposition",
tags=["Banks"]
)
async def api_update_bank_disposition(bank_id: str,
request: UpdateDispositionRequest
@@ -1590,7 +1602,8 @@ This operation cannot be undone.
response_model=BackgroundResponse,
summary="Add/merge memory bank background",
description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.",
operation_id="add_bank_background"
operation_id="add_bank_background",
tags=["Banks"]
)
async def api_add_bank_background(bank_id: str,
request: AddBackgroundRequest
@@ -1620,7 +1633,8 @@ This operation cannot be undone.
response_model=BankProfileResponse,
summary="Create or update memory bank",
description="Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.",
operation_id="create_or_update_bank"
operation_id="create_or_update_bank",
tags=["Banks"]
)
async def api_create_or_update_bank(bank_id: str,
request: CreateBankRequest
@@ -1686,43 +1700,55 @@ This operation cannot be undone.
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,
summary="Retain memories",
description="""
Retain memory items with automatic fact extraction.
This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing
via the async parameter.
Features:
- Efficient batch processing
- Automatic fact extraction from natural language
- Entity recognition and linking
- Document tracking with automatic upsert (when document_id is provided on items)
- Temporal and semantic linking
- Optional asynchronous processing
The system automatically:
1. Extracts semantic facts from the content
2. Generates embeddings
3. Deduplicates similar facts
4. Creates temporal, semantic, and entity links
5. Tracks document metadata
When async=true:
- Returns immediately after queuing the task
- Processing happens in the background
- Use the operations endpoint to monitor progress
When async=false (default):
- Waits for processing to complete
- Returns after all memories are stored
Note: If a memory item has a document_id that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). Items with the same document_id are grouped together for efficient processing.
""",
operation_id="retain_memories"
description="Retain memory items with automatic fact extraction.\n\n"
"This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter.\n\n"
"**Features:**\n"
"- Efficient batch processing\n"
"- Automatic fact extraction from natural language\n"
"- Entity recognition and linking\n"
"- Document tracking with automatic upsert (when document_id is provided)\n"
"- Temporal and semantic linking\n"
"- Optional asynchronous processing\n\n"
"**The system automatically:**\n"
"1. Extracts semantic facts from the content\n"
"2. Generates embeddings\n"
"3. Deduplicates similar facts\n"
"4. Creates temporal, semantic, and entity links\n"
"5. Tracks document metadata\n\n"
"**When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress.\n\n"
"**When `async=false` (default):** Waits for processing to complete.\n\n"
"**Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior).",
operation_id="retain_memories",
tags=["Memory"]
)
async def api_retain(bank_id: str, request: RetainRequest):
"""Retain memories with optional async processing."""
@@ -1763,7 +1789,7 @@ This operation cannot be undone.
# 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
@@ -1803,7 +1829,8 @@ This operation cannot be undone.
response_model=DeleteResponse,
summary="Clear memory bank memories",
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
operation_id="clear_bank_memories"
operation_id="clear_bank_memories",
tags=["Memory"]
)
async def api_clear_bank_memories(bank_id: str,
type: Optional[str] = Query(None, description="Optional fact type filter (world, experience, opinion)")
+1 -5
View File
@@ -121,11 +121,7 @@ class MCPMiddleware:
self.app = app
self.memory = memory
self.mcp_server = create_mcp_server(memory)
# Use sse_app - http_app requires lifespan management that's complex with middleware
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
self.mcp_app = self.mcp_server.sse_app()
self.mcp_app = self.mcp_server.http_app()
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
+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()
-127
View File
@@ -1,127 +0,0 @@
"""
Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
import os
import signal
import sys
from typing import Optional
import uvicorn
from . import MemoryEngine
from .api import create_app
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: Optional[MemoryEngine] = None
def _cleanup():
"""Synchronous cleanup function to stop resources on exit."""
global _memory
if _memory is not None and _memory._pg0 is not None:
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(_memory._pg0.stop())
loop.close()
print("\npg0 stopped.")
except Exception as e:
print(f"\nError stopping pg0: {e}")
def _signal_handler(signum, frame):
"""Handle SIGINT/SIGTERM to ensure cleanup."""
print(f"\nReceived signal {signum}, shutting down...")
_cleanup()
sys.exit(0)
def main():
"""Main entry point for the CLI."""
global _memory
parser = argparse.ArgumentParser(
prog="hindsight-api",
description="Hindsight API Server",
)
parser.add_argument(
"--host", default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8888,
help="Port to bind to (default: 8888)"
)
parser.add_argument(
"--log-level", default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="Log level (default: info)"
)
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
args = parser.parse_args()
# Register cleanup handlers
atexit.register(_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Get configuration from environment variables
db_url = os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0")
llm_provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
llm_api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
llm_model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-20b")
llm_base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None
# Create MemoryEngine
_memory = MemoryEngine(
db_url=db_url,
memory_llm_provider=llm_provider,
memory_llm_api_key=llm_api_key,
memory_llm_model=llm_model,
memory_llm_base_url=llm_base_url,
)
# Create FastAPI app
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=True,
mcp_mount_path="/mcp",
initialize_memory=True,
)
# Prepare uvicorn config
uvicorn_config = {
"app": app,
"host": args.host,
"port": args.port,
"log_level": args.log_level,
"access_log": args.access_log,
}
print(f"\nStarting Hindsight API...")
print(f" URL: http://{args.host}:{args.port}")
print(f" Database: {db_url}")
print(f" LLM Provider: {llm_provider}")
print()
uvicorn.run(**uvicorn_config)
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
"""
Centralized configuration for Hindsight API.
All environment variables and their defaults are defined here.
"""
import os
from dataclasses import dataclass
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_HOST = "HINDSIGHT_API_HOST"
ENV_PORT = "HINDSIGHT_API_PORT"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8888
DEFAULT_LOG_LEVEL = "info"
DEFAULT_MCP_ENABLED = True
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
# Required embedding dimension for database schema
EMBEDDING_DIMENSION = 384
@dataclass
class HindsightConfig:
"""Configuration container for Hindsight API."""
# Database
database_url: str
# LLM
llm_provider: str
llm_api_key: Optional[str]
llm_model: str
llm_base_url: Optional[str]
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_tei_url: Optional[str]
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_tei_url: Optional[str]
# Server
host: str
port: int
log_level: str
mcp_enabled: bool
# Recall
graph_retriever: str
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
return cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
# LLM
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
llm_api_key=os.getenv(ENV_LLM_API_KEY),
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
)
def get_llm_base_url(self) -> str:
"""Get the LLM base URL, with provider-specific defaults."""
if self.llm_base_url:
return self.llm_base_url
provider = self.llm_provider.lower()
if provider == "groq":
return "https://api.groq.com/openai/v1"
elif provider == "ollama":
return "http://localhost:11434/v1"
else:
return ""
def get_python_log_level(self) -> int:
"""Get the Python logging level from the configured log level string."""
log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
}
return log_level_map.get(self.log_level.lower(), logging.INFO)
def configure_logging(self) -> None:
"""Configure Python logging based on the log level."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
logger.info(f"Graph retriever: {self.graph_retriever}")
def get_config() -> HindsightConfig:
"""Get the current configuration from environment variables."""
return HindsightConfig.from_env()
@@ -9,7 +9,8 @@ This package contains all the implementation details of the memory engine:
from .memory_engine import MemoryEngine
from .db_utils import acquire_with_retry
from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .search.trace import (
SearchTrace,
QueryInfo,
@@ -29,7 +30,11 @@ __all__ = [
"MemoryEngine",
"acquire_with_retry",
"Embeddings",
"SentenceTransformersEmbeddings",
"LocalSTEmbeddings",
"RemoteTEIEmbeddings",
"CrossEncoderModel",
"LocalSTCrossEncoder",
"RemoteTEICrossEncoder",
"SearchTrace",
"SearchTracer",
"QueryInfo",
@@ -2,10 +2,23 @@
Cross-encoder abstraction for reranking.
Provides an interface for reranking with different backends.
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple
from typing import List, Tuple, Optional
import logging
import os
import httpx
from ..config import (
ENV_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_TEI_URL,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_LOCAL_MODEL,
)
logger = logging.getLogger(__name__)
@@ -17,12 +30,18 @@ class CrossEncoderModel(ABC):
Cross-encoders take query-document pairs and return relevance scores.
"""
@property
@abstractmethod
def load(self) -> None:
"""
Load the cross-encoder model.
def provider_name(self) -> str:
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
This should be called during initialization to load the model
@abstractmethod
async def initialize(self) -> None:
"""
Initialize the cross-encoder model asynchronously.
This should be called during startup to load/connect to the model
and avoid cold start latency on first predict() call.
"""
pass
@@ -41,11 +60,11 @@ class CrossEncoderModel(ABC):
pass
class SentenceTransformersCrossEncoder(CrossEncoderModel):
class LocalSTCrossEncoder(CrossEncoderModel):
"""
Cross-encoder implementation using SentenceTransformers.
Local cross-encoder implementation using SentenceTransformers.
Call load() during initialization to load the model and avoid cold starts.
Call initialize() during startup to load the model and avoid cold starts.
Default model is cross-encoder/ms-marco-MiniLM-L-6-v2:
- Fast inference (~80ms for 100 pairs on CPU)
@@ -53,18 +72,22 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
- Trained for passage re-ranking
"""
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
def __init__(self, model_name: Optional[str] = None):
"""
Initialize SentenceTransformers cross-encoder.
Initialize local SentenceTransformers cross-encoder.
Args:
model_name: Name of the CrossEncoder model to use.
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
"""
self.model_name = model_name
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self._model = None
def load(self) -> None:
@property
def provider_name(self) -> str:
return "local"
async def initialize(self) -> None:
"""Load the cross-encoder model."""
if self._model is not None:
return
@@ -73,18 +96,13 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
from sentence_transformers import CrossEncoder
except ImportError:
raise ImportError(
"sentence-transformers is required for SentenceTransformersCrossEncoder. "
"sentence-transformers is required for LocalSTCrossEncoder. "
"Install it with: pip install sentence-transformers"
)
logger.info(f"Loading cross-encoder 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},
)
logger.info("Cross-encoder model loaded")
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
self._model = CrossEncoder(self.model_name)
logger.info("Reranker: local provider initialized")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
@@ -97,6 +115,187 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
List of relevance scores (raw logits from the model)
"""
if self._model is None:
self.load()
raise RuntimeError("Reranker not initialized. Call initialize() first.")
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, 'tolist') else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
"""
Remote cross-encoder implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
TEI supports reranking via the /rerank endpoint.
See: https://github.com/huggingface/text-embeddings-inference
Note: The TEI server must be running a cross-encoder/reranker model.
"""
def __init__(
self,
base_url: str,
timeout: float = 30.0,
batch_size: int = 32,
max_retries: int = 3,
retry_delay: float = 0.5,
):
"""
Initialize remote TEI cross-encoder client.
Args:
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
timeout: Request timeout in seconds (default: 30.0)
batch_size: Maximum batch size for rerank requests (default: 32)
max_retries: Maximum number of retries for failed requests (default: 3)
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
return "tei"
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
for attempt in range(self.max_retries + 1):
try:
if method == "GET":
response = self._client.get(url, **kwargs)
else:
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
raise
raise last_error
async def initialize(self) -> None:
"""Initialize the HTTP client and verify server connectivity."""
if self._client is not None:
return
logger.info(f"Reranker: initializing TEI provider at {self.base_url}")
self._client = httpx.Client(timeout=self.timeout)
# Verify server is reachable and get model info
try:
response = self._request_with_retry("GET", f"{self.base_url}/info")
info = response.json()
self._model_id = info.get("model_id", "unknown")
logger.info(f"Reranker: TEI provider initialized (model: {self._model_id})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
Score query-document pairs using the remote TEI reranker.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
all_scores = []
# Process in batches
for i in range(0, len(pairs), self.batch_size):
batch = pairs[i:i + self.batch_size]
# TEI rerank endpoint expects query and texts separately
# All pairs in a batch should have the same query for optimal performance
# but we handle mixed queries by making separate requests per unique query
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(batch):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
batch_scores = [0.0] * len(batch)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
try:
response = self._request_with_retry(
"POST",
f"{self.base_url}/rerank",
json={
"query": query,
"texts": texts,
"return_text": False,
},
)
results = response.json()
# TEI returns results sorted by score descending, with original index
for result in results:
original_idx = result["index"]
score = result["score"]
# Map back to batch position
batch_scores[indices[original_idx]] = score
except httpx.HTTPError as e:
raise RuntimeError(f"TEI rerank request failed: {e}")
all_scores.extend(batch_scores)
return all_scores
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on environment variables.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured CrossEncoderModel instance
"""
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
if provider == "tei":
url = os.environ.get(ENV_RERANKER_TEI_URL)
if not url:
raise ValueError(
f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'"
)
return RemoteTEICrossEncoder(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
return LocalSTCrossEncoder(model_name=model_name)
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'"
)
+193 -19
View File
@@ -5,16 +5,27 @@ Provides an interface for generating embeddings with different backends.
IMPORTANT: All embeddings must produce 384-dimensional vectors to match
the database schema (pgvector column defined as vector(384)).
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List
from typing import List, Optional
import logging
import os
import httpx
from ..config import (
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_TEI_URL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
EMBEDDING_DIMENSION,
)
logger = logging.getLogger(__name__)
# Fixed embedding dimension required by database schema
EMBEDDING_DIMENSION = 384
class Embeddings(ABC):
"""
@@ -24,12 +35,18 @@ class Embeddings(ABC):
the database schema.
"""
@property
@abstractmethod
def load(self) -> None:
"""
Load the embedding model.
def provider_name(self) -> str:
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
This should be called during initialization to load the model
@abstractmethod
async def initialize(self) -> None:
"""
Initialize the embedding model asynchronously.
This should be called during startup to load/connect to the model
and avoid cold start latency on first encode() call.
"""
pass
@@ -48,29 +65,33 @@ class Embeddings(ABC):
pass
class SentenceTransformersEmbeddings(Embeddings):
class LocalSTEmbeddings(Embeddings):
"""
Embeddings implementation using SentenceTransformers.
Local embeddings implementation using SentenceTransformers.
Call load() during initialization to load the model and avoid cold starts.
Call initialize() during startup to load the model and avoid cold starts.
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
embeddings matching the database schema.
"""
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
def __init__(self, model_name: Optional[str] = None):
"""
Initialize SentenceTransformers embeddings.
Initialize local SentenceTransformers embeddings.
Args:
model_name: Name of the SentenceTransformer model to use.
Must produce 384-dimensional embeddings.
Default: BAAI/bge-small-en-v1.5
"""
self.model_name = model_name
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self._model = None
def load(self) -> None:
@property
def provider_name(self) -> str:
return "local"
async def initialize(self) -> None:
"""Load the embedding model."""
if self._model is not None:
return
@@ -79,11 +100,11 @@ class SentenceTransformersEmbeddings(Embeddings):
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for SentenceTransformersEmbeddings. "
"sentence-transformers is required for LocalSTEmbeddings. "
"Install it with: pip install sentence-transformers"
)
logger.info(f"Loading embedding model: {self.model_name}...")
logger.info(f"Embeddings: 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 = SentenceTransformer(
@@ -100,7 +121,7 @@ class SentenceTransformersEmbeddings(Embeddings):
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
)
logger.info(f"Model loaded (embedding dim: {model_dim})")
logger.info(f"Embeddings: local provider initialized (dim: {model_dim})")
def encode(self, texts: List[str]) -> List[List[float]]:
"""
@@ -113,6 +134,159 @@ class SentenceTransformersEmbeddings(Embeddings):
List of 384-dimensional embedding vectors
"""
if self._model is None:
self.load()
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
class RemoteTEIEmbeddings(Embeddings):
"""
Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
TEI provides a high-performance inference server for embedding models.
See: https://github.com/huggingface/text-embeddings-inference
The server should be running a model that produces 384-dimensional embeddings.
"""
def __init__(
self,
base_url: str,
timeout: float = 30.0,
batch_size: int = 32,
max_retries: int = 3,
retry_delay: float = 0.5,
):
"""
Initialize remote TEI embeddings client.
Args:
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
timeout: Request timeout in seconds (default: 30.0)
batch_size: Maximum batch size for embedding requests (default: 32)
max_retries: Maximum number of retries for failed requests (default: 3)
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
return "tei"
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
for attempt in range(self.max_retries + 1):
try:
if method == "GET":
response = self._client.get(url, **kwargs)
else:
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
raise
raise last_error
async def initialize(self) -> None:
"""Initialize the HTTP client and verify server connectivity."""
if self._client is not None:
return
logger.info(f"Embeddings: initializing TEI provider at {self.base_url}")
self._client = httpx.Client(timeout=self.timeout)
# Verify server is reachable and get model info
try:
response = self._request_with_retry("GET", f"{self.base_url}/info")
info = response.json()
self._model_id = info.get("model_id", "unknown")
logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def encode(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using the remote TEI server.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i:i + self.batch_size]
try:
response = self._request_with_retry(
"POST",
f"{self.base_url}/embed",
json={"inputs": batch},
)
batch_embeddings = response.json()
all_embeddings.extend(batch_embeddings)
except httpx.HTTPError as e:
raise RuntimeError(f"TEI embedding request failed: {e}")
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on environment variables.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured Embeddings instance
"""
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
if provider == "tei":
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
if not url:
raise ValueError(
f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'"
)
return RemoteTEIEmbeddings(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
return LocalSTEmbeddings(model_name=model_name)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei'"
)
+143 -147
View File
@@ -34,8 +34,12 @@ class OutputTooLongError(Exception):
pass
class LLMConfig:
"""Configuration for an LLM provider."""
class LLMProvider:
"""
Unified LLM provider.
Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini.
"""
def __init__(
self,
@@ -43,16 +47,17 @@ class LLMConfig:
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
reasoning_effort: str = "low",
):
"""
Initialize LLM configuration.
Initialize LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama"). Required.
api_key: API key. Required.
base_url: Base URL. Required.
model: Model name. Required.
provider: Provider name ("openai", "groq", "ollama", "gemini").
api_key: API key.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
"""
self.provider = provider.lower()
self.api_key = api_key
@@ -61,9 +66,10 @@ class LLMConfig:
self.reasoning_effort = reasoning_effort
# Validate provider
if self.provider not in ["openai", "groq", "ollama", "gemini"]:
valid_providers = ["openai", "groq", "ollama", "gemini"]
if self.provider not in valid_providers:
raise ValueError(
f"Invalid LLM provider: {self.provider}. Must be 'openai', 'groq', 'ollama', or 'gemini'."
f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}"
)
# Set default base URLs
@@ -74,129 +80,167 @@ class LLMConfig:
self.base_url = "http://localhost:11434/v1"
# Validate API key (not needed for ollama)
if self.provider not in ["ollama"] and not self.api_key:
raise ValueError(
f"API key not found for {self.provider}"
)
if self.provider != "ollama" and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
# Create client (private - use .call() method instead)
# Disable automatic retries - we handle retries in the call() method
# Create client based on provider
if self.provider == "gemini":
self._gemini_client = genai.Client(api_key=self.api_key)
self._client = None # Not used for Gemini
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
elif self.base_url:
self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, max_retries=0)
self._gemini_client = None
else:
self._client = AsyncOpenAI(api_key=self.api_key, 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,
messages: List[Dict[str, str]],
response_format: Optional[Any] = None,
max_completion_tokens: Optional[int] = None,
temperature: Optional[float] = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
**kwargs
) -> Any:
"""
Make an LLM API call with consistent configuration and retry logic.
Make an LLM API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'
response_format: Optional Pydantic model for structured output
scope: Scope identifier (e.g., 'memory', 'judge') for future tracking
max_retries: Maximum number of retry attempts (default: 5)
initial_backoff: Initial backoff time in seconds (default: 1.0)
max_backoff: Maximum backoff time in seconds (default: 60.0)
**kwargs: Additional parameters to pass to the API (temperature, max_tokens, etc.)
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
Returns:
Parsed response if response_format is provided, otherwise the text content
Parsed response if response_format is provided, otherwise text content.
Raises:
Exception: Re-raises any API errors after all retries are exhausted
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
# Use global semaphore to limit concurrent requests
async with _global_llm_semaphore:
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, **kwargs)
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,
**kwargs
}
# 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
# For GPT-4o models, cap to 16384
is_gpt4_model = any(x in model_lower for x in ["gpt-4.1", "gpt-4-"])
is_gpt4o_model = "gpt-4o" in model_lower
if max_completion_tokens is not None:
if is_gpt4o_model and max_completion_tokens > 16384:
max_completion_tokens = 16384
elif 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
# 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
if self.provider == "groq":
call_params["extra_body"] = {
"service_tier": "auto",
"reasoning_effort": self.reasoning_effort,
"include_reasoning": False, # Disable hidden reasoning tokens
}
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
for attempt in range(max_retries + 1):
try:
# Use the appropriate response format
if response_format is not None:
# Use JSON mode instead of strict parse for flexibility with optional fields
# This allows the LLM to omit optional fields without validation errors
# Add schema to the system message
# Add schema to system message for JSON mode
if 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)}"
# Add schema to the system message if present, otherwise prepend as user message
if call_params['messages'] and call_params['messages'][0].get('role') == 'system':
call_params['messages'][0]['content'] += schema_msg
else:
# No system message, add schema instruction to first user message
if call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
elif call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
call_params['response_format'] = {"type": "json_object"}
response = await self._client.chat.completions.create(**call_params)
# Parse the JSON response
content = response.choices[0].message.content
json_data = json.loads(content)
# Return raw JSON if skip_validation is True, otherwise validate with Pydantic
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
# Standard completion and return text content
response = await self._client.chat.completions.create(**call_params)
result = response.choices[0].message.content
# Log call details only if it takes more than 5 seconds
# Log slow calls
duration = time.time() - start_time
usage = response.usage
if duration > 10.0:
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
# Check for cached tokens (OpenAI/Groq may include this)
cached_tokens = 0
if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, 'cached_tokens', 0) or 0
@@ -210,17 +254,16 @@ class LLMConfig:
return result
except LengthFinishReasonError as e:
# Output exceeded token limits - raise bridge exception for caller to handle
logger.warning(f"LLM output exceeded token limits: {str(e)}")
raise OutputTooLongError(
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
# Handle connection errors (server disconnected, network issues) with retry
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
@@ -229,19 +272,18 @@ class LLMConfig:
raise
except APIStatusError as e:
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
if e.status_code in (401, 403):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
last_exception = e
if attempt < max_retries:
# Calculate exponential backoff with jitter
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
# Add jitter (±20%)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
# Only log if it's a non-retryable error or final attempt
# Silent retry for common transient errors like capacity exceeded
await asyncio.sleep(sleep_time)
else:
# Log only on final failed attempt
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
@@ -249,7 +291,6 @@ class LLMConfig:
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
raise
# This should never be reached, but just in case
if last_exception:
raise last_exception
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
@@ -263,13 +304,11 @@ class LLMConfig:
max_backoff: float,
skip_validation: bool,
start_time: float,
**kwargs
) -> Any:
"""Handle Gemini-specific API calls using google-genai SDK."""
) -> Any:
"""Handle Gemini-specific API calls."""
import json
# Convert OpenAI-style messages to Gemini format
# Gemini uses 'user' and 'model' roles, and system instructions are separate
system_instruction = None
gemini_contents = []
@@ -278,7 +317,6 @@ class LLMConfig:
content = msg.get('content', '')
if role == 'system':
# Accumulate system messages as system instruction
if system_instruction:
system_instruction += "\n\n" + content
else:
@@ -288,7 +326,7 @@ class LLMConfig:
role="model",
parts=[genai_types.Part(text=content)]
))
else: # user or any other role
else:
gemini_contents.append(genai_types.Content(
role="user",
parts=[genai_types.Part(text=content)]
@@ -307,13 +345,8 @@ class LLMConfig:
config_kwargs = {}
if system_instruction:
config_kwargs['system_instruction'] = system_instruction
if 'temperature' in kwargs:
config_kwargs['temperature'] = kwargs['temperature']
if 'max_tokens' in kwargs:
config_kwargs['max_output_tokens'] = kwargs['max_tokens']
if response_format is not None:
config_kwargs['response_mime_type'] = 'application/json'
# Pass the Pydantic model directly as response_schema for structured output
config_kwargs['response_schema'] = response_format
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
@@ -330,9 +363,8 @@ class LLMConfig:
content = response.text
# Handle empty/None response (can happen with content filtering or timeouts)
# Handle empty response
if content is None:
# Check if there's a block reason
block_reason = None
if hasattr(response, 'candidates') and response.candidates:
candidate = response.candidates[0]
@@ -340,18 +372,15 @@ class LLMConfig:
block_reason = candidate.finish_reason
if attempt < max_retries:
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying... (attempt {attempt + 1}/{max_retries + 1})")
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 (reason: {block_reason})")
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
if response_format is not None:
# Parse the JSON response
json_data = json.loads(content)
# Return raw JSON if skip_validation is True, otherwise validate with Pydantic
if skip_validation:
result = json_data
else:
@@ -359,42 +388,42 @@ class LLMConfig:
else:
result = content
# Log call details only if it takes more than 10 seconds
# 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
# Check for cached tokens (Gemini uses cached_content_token_count)
cached_tokens = getattr(usage, 'cached_content_token_count', 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}{cache_info}, "
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:
# Handle truncated JSON responses (often from MAX_TOKENS) with retry
last_exception = e
if attempt < max_retries:
logger.warning(f"Gemini returned invalid JSON (truncated response?), retrying... (attempt {attempt + 1}/{max_retries + 1})")
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: {str(e)}")
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
raise
except genai_errors.APIError as e:
# Handle rate limits and server errors with retry
if e.code in (429, 503, 500):
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors, and other client errors like 400)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
await asyncio.sleep(sleep_time)
await asyncio.sleep(backoff + jitter)
else:
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
raise
@@ -408,25 +437,16 @@ class LLMConfig:
if last_exception:
raise last_exception
raise RuntimeError(f"Gemini call failed after all retries with no exception captured")
raise RuntimeError(f"Gemini call failed after all retries")
@classmethod
def for_memory(cls) -> "LLMConfig":
"""Create configuration for memory operations from environment variables."""
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
# Set default base URL if not provided
if not base_url:
if provider == "groq":
base_url = "https://api.groq.com/openai/v1"
elif provider == "ollama":
base_url = "http://localhost:11434/v1"
else:
base_url = ""
return cls(
provider=provider,
api_key=api_key,
@@ -436,27 +456,13 @@ class LLMConfig:
)
@classmethod
def for_answer_generation(cls) -> "LLMConfig":
"""
Create configuration for answer generation operations from environment variables.
Falls back to memory LLM config if answer-specific config not set.
"""
# Check if answer-specific config exists, otherwise fall back to memory config
def for_answer_generation(cls) -> "LLMProvider":
"""Create provider for answer generation. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL"))
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
# Set default base URL if not provided
if not base_url:
if provider == "groq":
base_url = "https://api.groq.com/openai/v1"
elif provider == "ollama":
base_url = "http://localhost:11434/v1"
else:
base_url = ""
return cls(
provider=provider,
api_key=api_key,
@@ -466,27 +472,13 @@ class LLMConfig:
)
@classmethod
def for_judge(cls) -> "LLMConfig":
"""
Create configuration for judge/evaluator operations from environment variables.
Falls back to memory LLM config if judge-specific config not set.
"""
# Check if judge-specific config exists, otherwise fall back to memory config
def for_judge(cls) -> "LLMProvider":
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL"))
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
# Set default base URL if not provided
if not base_url:
if provider == "groq":
base_url = "https://api.groq.com/openai/v1"
elif provider == "ollama":
base_url = "http://localhost:11434/v1"
else:
base_url = ""
return cls(
provider=provider,
api_key=api_key,
@@ -494,3 +486,7 @@ class LLMConfig:
model=model,
reasoning_effort="high"
)
# Backwards compatibility alias
LLMConfig = LLMProvider
@@ -11,17 +11,20 @@ This implements a sophisticated memory architecture that combines:
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict
from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict, TYPE_CHECKING
import asyncpg
import asyncio
from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .cross_encoder import CrossEncoderModel
from .embeddings import Embeddings, create_embeddings_from_env
from .cross_encoder import CrossEncoderModel, create_cross_encoder_from_env
import time
import numpy as np
import uuid
import logging
from pydantic import BaseModel, Field
if TYPE_CHECKING:
from ..config import HindsightConfig
class RetainContentDict(TypedDict, total=False):
"""Type definition for content items in retain_batch_async.
@@ -99,10 +102,10 @@ class MemoryEngine:
def __init__(
self,
db_url: str,
memory_llm_provider: str,
memory_llm_api_key: str,
memory_llm_model: str,
db_url: Optional[str] = None,
memory_llm_provider: Optional[str] = None,
memory_llm_api_key: Optional[str] = None,
memory_llm_model: Optional[str] = None,
memory_llm_base_url: Optional[str] = None,
embeddings: Optional[Embeddings] = None,
cross_encoder: Optional[CrossEncoderModel] = None,
@@ -115,26 +118,34 @@ class MemoryEngine:
"""
Initialize the temporal + semantic memory system.
All parameters are optional and will be read from environment variables if not provided.
See hindsight_api.config for environment variable names and defaults.
Args:
db_url: PostgreSQL connection URL (postgresql://user:pass@host:port/dbname). Required.
db_url: PostgreSQL connection URL. Defaults to HINDSIGHT_API_DATABASE_URL env var or "pg0".
Also supports pg0 URLs: "pg0" or "pg0://instance-name" or "pg0://instance-name:port"
memory_llm_provider: LLM provider for memory operations: "openai", "groq", or "ollama". Required.
memory_llm_api_key: API key for the LLM provider. Required.
memory_llm_model: Model name to use for all memory operations (put/think/opinions). Required.
memory_llm_base_url: Base URL for the LLM API. Optional. Defaults based on provider:
- groq: https://api.groq.com/openai/v1
- ollama: http://localhost:11434/v1
embeddings: Embeddings implementation to use. If not provided, uses SentenceTransformersEmbeddings
cross_encoder: Cross-encoder model for reranking. If not provided, uses default when cross-encoder reranker is selected
query_analyzer: Query analyzer implementation to use. If not provided, uses TransformerQueryAnalyzer
memory_llm_provider: LLM provider. Defaults to HINDSIGHT_API_LLM_PROVIDER env var or "groq".
memory_llm_api_key: API key for the LLM provider. Defaults to HINDSIGHT_API_LLM_API_KEY env var.
memory_llm_model: Model name. Defaults to HINDSIGHT_API_LLM_MODEL env var.
memory_llm_base_url: Base URL for the LLM API. Defaults based on provider.
embeddings: Embeddings implementation. If not provided, created from env vars.
cross_encoder: Cross-encoder model. If not provided, created from env vars.
query_analyzer: Query analyzer implementation. If not provided, uses DateparserQueryAnalyzer.
pool_min_size: Minimum number of connections in the pool (default: 5)
pool_max_size: Maximum number of connections in the pool (default: 100)
Increase for parallel think/search operations (e.g., 200-300 for 100+ parallel thinks)
task_backend: Custom task backend for async task execution. If not provided, uses AsyncIOQueueBackend
task_backend: Custom task backend. If not provided, uses AsyncIOQueueBackend.
run_migrations: Whether to run database migrations during initialize(). Default: True
"""
if not db_url:
raise ValueError("Database url is required")
# Load config from environment for any missing parameters
from ..config import get_config
config = get_config()
# Apply defaults from config
db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
memory_llm_model = memory_llm_model or config.llm_model
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
# Track pg0 instance (if used)
self._pg0: Optional[EmbeddedPostgres] = None
self._pg0_instance_name: Optional[str] = None
@@ -184,11 +195,11 @@ class MemoryEngine:
# Initialize entity resolver (will be created in initialize())
self.entity_resolver = None
# Initialize embeddings
# Initialize embeddings (from env vars if not provided)
if embeddings is not None:
self.embeddings = embeddings
else:
self.embeddings = SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
self.embeddings = create_embeddings_from_env()
# Initialize query analyzer
if query_analyzer is not None:
@@ -319,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)
@@ -414,32 +425,46 @@ class MemoryEngine:
if not was_already_running:
self._pg0 = pg0
def load_embeddings():
"""Load embedding model (CPU-bound)."""
self.embeddings.load()
async def init_embeddings():
"""Initialize embedding model."""
# For local providers, run in thread pool to avoid blocking event loop
if self.embeddings.provider_name == "local":
await loop.run_in_executor(
None,
lambda: asyncio.run(self.embeddings.initialize())
)
else:
await self.embeddings.initialize()
def load_cross_encoder():
"""Load cross-encoder model (CPU-bound)."""
self._cross_encoder_reranker.cross_encoder.load()
async def init_cross_encoder():
"""Initialize cross-encoder model."""
cross_encoder = self._cross_encoder_reranker.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
await loop.run_in_executor(
None,
lambda: asyncio.run(cross_encoder.initialize())
)
else:
await cross_encoder.initialize()
def load_query_analyzer():
"""Load query analyzer model (CPU-bound)."""
self.query_analyzer.load()
async def init_query_analyzer():
"""Initialize query analyzer model."""
# Query analyzer load is sync and CPU-bound
await loop.run_in_executor(None, self.query_analyzer.load)
# Run pg0 and all model loads in parallel
# pg0 is async (IO-bound), models are sync (CPU-bound in thread pool)
# Use 3 workers to load all models concurrently
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
# Start all tasks
pg0_task = asyncio.create_task(start_pg0())
embeddings_future = loop.run_in_executor(executor, load_embeddings)
cross_encoder_future = loop.run_in_executor(executor, load_cross_encoder)
query_analyzer_future = loop.run_in_executor(executor, load_query_analyzer)
async def verify_llm():
"""Verify LLM connection is working."""
await self._llm_config.verify_connection()
# Wait for all to complete
await asyncio.gather(
pg0_task, embeddings_future, cross_encoder_future, query_analyzer_future
)
# 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
if self._run_migrations:
@@ -1131,22 +1156,22 @@ class MemoryEngine:
aggregated_timings = {"semantic": 0.0, "bm25": 0.0, "graph": 0.0, "temporal": 0.0}
detected_temporal_constraint = None
for idx, (ft_semantic, ft_bm25, ft_graph, ft_temporal, ft_timings, ft_temporal_constraint) in enumerate(all_retrievals):
for idx, retrieval_result in enumerate(all_retrievals):
# Log fact types in this retrieval batch
ft_name = fact_type[idx] if idx < len(fact_type) else "unknown"
logger.debug(f"[RECALL {recall_id}] Fact type '{ft_name}': semantic={len(ft_semantic)}, bm25={len(ft_bm25)}, graph={len(ft_graph)}, temporal={len(ft_temporal) if ft_temporal else 0}")
logger.debug(f"[RECALL {recall_id}] Fact type '{ft_name}': semantic={len(retrieval_result.semantic)}, bm25={len(retrieval_result.bm25)}, graph={len(retrieval_result.graph)}, temporal={len(retrieval_result.temporal) if retrieval_result.temporal else 0}")
semantic_results.extend(ft_semantic)
bm25_results.extend(ft_bm25)
graph_results.extend(ft_graph)
if ft_temporal:
temporal_results.extend(ft_temporal)
semantic_results.extend(retrieval_result.semantic)
bm25_results.extend(retrieval_result.bm25)
graph_results.extend(retrieval_result.graph)
if retrieval_result.temporal:
temporal_results.extend(retrieval_result.temporal)
# Track max timing for each method (since they run in parallel across fact types)
for method, duration in ft_timings.items():
aggregated_timings[method] = max(aggregated_timings[method], duration)
for method, duration in retrieval_result.timings.items():
aggregated_timings[method] = max(aggregated_timings.get(method, 0.0), duration)
# Capture temporal constraint (same across all fact types)
if ft_temporal_constraint:
detected_temporal_constraint = ft_temporal_constraint
if retrieval_result.temporal_constraint:
detected_temporal_constraint = retrieval_result.temporal_constraint
# If no temporal results from any fact type, set to None
if not temporal_results:
@@ -1178,49 +1203,57 @@ class MemoryEngine:
temporal_info = f" | temporal_range={start_dt.strftime('%Y-%m-%d')} to {end_dt.strftime('%Y-%m-%d')}"
log_buffer.append(f" [2] {total_retrievals}-way retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {step_duration:.3f}s{temporal_info}")
# Record retrieval results for tracer (convert typed results to old format)
# Record retrieval results for tracer - per fact type
if tracer:
# Convert RetrievalResult to old tuple format for tracer
def to_tuple_format(results):
return [(r.id, r.__dict__) for r in results]
# Add semantic retrieval results
tracer.add_retrieval_results(
method_name="semantic",
results=to_tuple_format(semantic_results),
duration_seconds=aggregated_timings["semantic"],
score_field="similarity",
metadata={"limit": thinking_budget}
)
# Add retrieval results per fact type (to show parallel execution in UI)
for idx, rr in enumerate(all_retrievals):
ft_name = fact_type[idx] if idx < len(fact_type) else "unknown"
# Add BM25 retrieval results
tracer.add_retrieval_results(
method_name="bm25",
results=to_tuple_format(bm25_results),
duration_seconds=aggregated_timings["bm25"],
score_field="bm25_score",
metadata={"limit": thinking_budget}
)
# Add graph retrieval results
tracer.add_retrieval_results(
method_name="graph",
results=to_tuple_format(graph_results),
duration_seconds=aggregated_timings["graph"],
score_field="similarity", # Graph uses similarity for activation
metadata={"budget": thinking_budget}
)
# Add temporal retrieval results if present
if temporal_results:
# Add semantic retrieval results for this fact type
tracer.add_retrieval_results(
method_name="temporal",
results=to_tuple_format(temporal_results),
duration_seconds=aggregated_timings["temporal"],
score_field="temporal_score",
metadata={"budget": thinking_budget}
method_name="semantic",
results=to_tuple_format(rr.semantic),
duration_seconds=rr.timings.get("semantic", 0.0),
score_field="similarity",
metadata={"limit": thinking_budget},
fact_type=ft_name
)
# Add BM25 retrieval results for this fact type
tracer.add_retrieval_results(
method_name="bm25",
results=to_tuple_format(rr.bm25),
duration_seconds=rr.timings.get("bm25", 0.0),
score_field="bm25_score",
metadata={"limit": thinking_budget},
fact_type=ft_name
)
# Add graph retrieval results for this fact type
tracer.add_retrieval_results(
method_name="graph",
results=to_tuple_format(rr.graph),
duration_seconds=rr.timings.get("graph", 0.0),
score_field="activation",
metadata={"budget": thinking_budget},
fact_type=ft_name
)
# Add temporal retrieval results for this fact type (even if empty, to show it ran)
if rr.temporal is not None:
tracer.add_retrieval_results(
method_name="temporal",
results=to_tuple_format(rr.temporal),
duration_seconds=rr.timings.get("temporal", 0.0),
score_field="temporal_score",
metadata={"budget": thinking_budget},
fact_type=ft_name
)
# Record entry points (from semantic results) for legacy graph view
for rank, retrieval in enumerate(semantic_results[:10], start=1): # Top 10 as entry points
tracer.add_entry_point(retrieval.id, retrieval.text, retrieval.similarity or 0.0, rank)
@@ -1262,31 +1295,24 @@ class MemoryEngine:
step_duration = time.time() - step_start
log_buffer.append(f" [4] Reranking: {len(scored_results)} candidates scored in {step_duration:.3f}s")
if tracer:
# Convert to old format for tracer
results_dict = [sr.to_dict() for sr in scored_results]
tracer_merged = [(mc.id, mc.retrieval.__dict__, {"rrf_score": mc.rrf_score, **mc.source_ranks})
for mc in merged_candidates]
tracer.add_reranked(results_dict, tracer_merged)
tracer.add_phase_metric("reranking", step_duration, {
"reranker_type": "cross-encoder",
"candidates_reranked": len(scored_results)
})
# Step 4.5: Combine cross-encoder score with retrieval signals
# This preserves retrieval work (RRF, temporal, recency) instead of pure cross-encoder ranking
if scored_results:
# Normalize RRF scores to [0, 1] range
# Normalize RRF scores to [0, 1] range using min-max normalization
rrf_scores = [sr.candidate.rrf_score for sr in scored_results]
max_rrf = max(rrf_scores) if rrf_scores else 1.0
max_rrf = max(rrf_scores) if rrf_scores else 0.0
min_rrf = min(rrf_scores) if rrf_scores else 0.0
rrf_range = max_rrf - min_rrf if max_rrf > min_rrf else 1.0
rrf_range = max_rrf - min_rrf # Don't force to 1.0, let fallback handle it
# Calculate recency based on occurred_start (more recent = higher score)
now = utcnow()
for sr in scored_results:
# Normalize RRF score
sr.rrf_normalized = (sr.candidate.rrf_score - min_rrf) / rrf_range if rrf_range > 0 else 0.5
# Normalize RRF score (0-1 range, 0.5 if all same)
if rrf_range > 0:
sr.rrf_normalized = (sr.candidate.rrf_score - min_rrf) / rrf_range
else:
# All RRF scores are the same, use neutral value
sr.rrf_normalized = 0.5
# Calculate recency (decay over 365 days, minimum 0.1)
sr.recency = 0.5 # default for missing dates
@@ -1318,6 +1344,17 @@ class MemoryEngine:
scored_results.sort(key=lambda x: x.weight, reverse=True)
log_buffer.append(f" [4.6] Combined scoring: cross_encoder(0.6) + rrf(0.2) + temporal(0.1) + recency(0.1)")
# Add reranked results to tracer AFTER combined scoring (so normalized values are included)
if tracer:
results_dict = [sr.to_dict() for sr in scored_results]
tracer_merged = [(mc.id, mc.retrieval.__dict__, {"rrf_score": mc.rrf_score, **mc.source_ranks})
for mc in merged_candidates]
tracer.add_reranked(results_dict, tracer_merged)
tracer.add_phase_metric("reranking", step_duration, {
"reranker_type": "cross-encoder",
"candidates_reranked": len(scored_results)
})
# Step 5: Truncate to thinking_budget * 2 for token filtering
rerank_limit = thinking_budget * 2
top_scored = scored_results[:rerank_limit]
@@ -1771,10 +1808,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:
@@ -1819,10 +1860,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,
@@ -1831,7 +1873,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 = []
@@ -2692,7 +2734,7 @@ Guidelines:
],
scope="memory_think",
temperature=0.9,
max_tokens=1000
max_completion_tokens=1000
)
llm_time = time.time() - llm_start
@@ -273,7 +273,7 @@ Merged background:"""
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_tokens=8192
max_completion_tokens=8192
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
@@ -291,7 +291,7 @@ Merged background:"""
messages=messages,
scope="bank_background",
temperature=0.3,
max_tokens=8192
max_completion_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
@@ -579,7 +579,7 @@ Text:
response_format=FactExtractionResponse,
scope="memory_extract_facts",
temperature=0.1,
max_tokens=65000,
max_completion_tokens=65000,
skip_validation=True, # Get raw JSON, we'll validate leniently
)
@@ -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:
@@ -3,13 +3,27 @@ Search module for memory retrieval.
Provides modular search architecture:
- Retrieval: 4-way parallel (semantic + BM25 + graph + temporal)
- Graph retrieval: Pluggable strategies (BFS, PPR)
- Reranking: Pluggable strategies (heuristic, cross-encoder)
"""
from .retrieval import retrieve_parallel
from .retrieval import (
retrieve_parallel,
get_default_graph_retriever,
set_default_graph_retriever,
ParallelRetrievalResult,
)
from .graph_retrieval import GraphRetriever, BFSGraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .reranking import CrossEncoderReranker
__all__ = [
"retrieve_parallel",
"get_default_graph_retriever",
"set_default_graph_retriever",
"ParallelRetrievalResult",
"GraphRetriever",
"BFSGraphRetriever",
"MPFPGraphRetriever",
"CrossEncoderReranker",
]
@@ -0,0 +1,235 @@
"""
Graph retrieval strategies for memory recall.
This module provides an abstraction for graph-based memory retrieval,
allowing different algorithms (BFS spreading activation, PPR, etc.) to be
swapped without changing the rest of the recall pipeline.
"""
from abc import ABC, abstractmethod
from typing import List, Optional
from datetime import datetime
import logging
from .types import RetrievalResult
from ..db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
class GraphRetriever(ABC):
"""
Abstract base class for graph-based memory retrieval.
Implementations traverse the memory graph (entity links, temporal links,
causal links) to find relevant facts that might not be found by
semantic or keyword search alone.
"""
@property
@abstractmethod
def name(self) -> str:
"""Return identifier for this retrieval strategy (e.g., 'bfs', 'mpfp')."""
pass
@abstractmethod
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
"""
Retrieve relevant facts via graph traversal.
Args:
pool: Database connection pool
query_embedding_str: Query embedding as string (for finding entry points)
bank_id: Memory bank identifier
fact_type: Fact type to filter ('world', 'experience', 'opinion', 'observation')
budget: Maximum number of nodes to explore/return
query_text: Original query text (optional, for some strategies)
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
Returns:
List of RetrievalResult objects with activation scores set
"""
pass
class BFSGraphRetriever(GraphRetriever):
"""
Graph retrieval using BFS-style spreading activation.
Starting from semantic entry points, spreads activation through
the memory graph (entity, temporal, causal links) using breadth-first
traversal with decaying activation.
This is the original Hindsight graph retrieval algorithm.
"""
def __init__(
self,
entry_point_limit: int = 5,
entry_point_threshold: float = 0.5,
activation_decay: float = 0.8,
min_activation: float = 0.1,
batch_size: int = 20,
):
"""
Initialize BFS graph retriever.
Args:
entry_point_limit: Maximum number of entry points to start from
entry_point_threshold: Minimum semantic similarity for entry points
activation_decay: Decay factor per hop (activation *= decay)
min_activation: Minimum activation to continue spreading
batch_size: Number of nodes to process per batch (for neighbor fetching)
"""
self.entry_point_limit = entry_point_limit
self.entry_point_threshold = entry_point_threshold
self.activation_decay = activation_decay
self.min_activation = min_activation
self.batch_size = batch_size
@property
def name(self) -> str:
return "bfs"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
"""
Retrieve facts using BFS spreading activation.
Algorithm:
1. Find entry points (top semantic matches above threshold)
2. BFS traversal: visit neighbors, propagate decaying activation
3. Boost causal links (causes, enables, prevents)
4. Return visited nodes up to budget
Note: BFS finds its own entry points via embedding search.
The semantic_seeds and temporal_seeds parameters are accepted
for interface compatibility but not used.
"""
async with acquire_with_retry(pool) as conn:
return await self._retrieve_with_conn(
conn, query_embedding_str, bank_id, fact_type, budget
)
async def _retrieve_with_conn(
self,
conn,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
) -> List[RetrievalResult]:
"""Internal implementation with connection."""
# Step 1: Find entry points
entry_points = await conn.fetch(
"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str, bank_id, fact_type,
self.entry_point_threshold, self.entry_point_limit
)
if not entry_points:
return []
# Step 2: BFS spreading activation
visited = set()
results = []
queue = [
(RetrievalResult.from_db_row(dict(r)), r["similarity"])
for r in entry_points
]
budget_remaining = budget
while queue and budget_remaining > 0:
# Collect a batch of nodes to process
batch_nodes = []
batch_activations = {}
while queue and len(batch_nodes) < self.batch_size and budget_remaining > 0:
current, activation = queue.pop(0)
unit_id = current.id
if unit_id not in visited:
visited.add(unit_id)
budget_remaining -= 1
current.activation = activation
results.append(current)
batch_nodes.append(current.id)
batch_activations[unit_id] = activation
# Batch fetch neighbors
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.weight >= $2
AND mu.fact_type = $3
ORDER BY ml.weight DESC
LIMIT $4
""",
batch_nodes, self.min_activation, fact_type, max_neighbors
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id not in visited:
parent_id = str(n["from_unit_id"])
parent_activation = batch_activations.get(parent_id, 0.5)
# Boost causal links
link_type = n["link_type"]
base_weight = n["weight"]
if link_type in ("causes", "caused_by"):
causal_boost = 2.0
elif link_type in ("enables", "prevents"):
causal_boost = 1.5
else:
causal_boost = 1.0
effective_weight = base_weight * causal_boost
new_activation = parent_activation * effective_weight * self.activation_decay
if new_activation > self.min_activation:
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
return results
@@ -0,0 +1,454 @@
"""
Meta-Path Forward Push (MPFP) graph retrieval.
A sublinear graph traversal algorithm for memory retrieval over heterogeneous
graphs with multiple edge types (semantic, temporal, causal, entity).
Combines meta-path patterns from HIN literature with Forward Push local
propagation from Approximate PPR.
Key properties:
- Sublinear in graph size (threshold pruning bounds active nodes)
- Predefined patterns capture different retrieval intents
- All patterns run in parallel, results fused via RRF
- No LLM in the loop during traversal
"""
import asyncio
import logging
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from collections import defaultdict
from .types import RetrievalResult
from .graph_retrieval import GraphRetriever
from ..db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Data Classes
# -----------------------------------------------------------------------------
@dataclass
class EdgeTarget:
"""A neighbor node with its edge weight."""
node_id: str
weight: float
@dataclass
class TypedAdjacency:
"""Adjacency lists split by edge type."""
# edge_type -> from_node_id -> list of (to_node_id, weight)
graphs: Dict[str, Dict[str, List[EdgeTarget]]] = field(default_factory=dict)
def get_neighbors(self, edge_type: str, node_id: str) -> List[EdgeTarget]:
"""Get neighbors for a node via a specific edge type."""
return self.graphs.get(edge_type, {}).get(node_id, [])
def get_normalized_neighbors(
self,
edge_type: str,
node_id: str,
top_k: int
) -> List[EdgeTarget]:
"""Get top-k neighbors with weights normalized to sum to 1."""
neighbors = self.get_neighbors(edge_type, node_id)[:top_k]
if not neighbors:
return []
total = sum(n.weight for n in neighbors)
if total == 0:
return []
return [
EdgeTarget(node_id=n.node_id, weight=n.weight / total)
for n in neighbors
]
@dataclass
class PatternResult:
"""Result from a single pattern traversal."""
pattern: List[str]
scores: Dict[str, float] # node_id -> accumulated mass
@dataclass
class MPFPConfig:
"""Configuration for MPFP algorithm."""
alpha: float = 0.15 # teleport/keep probability
threshold: float = 1e-6 # mass pruning threshold (lower = explore more)
top_k_neighbors: int = 20 # fan-out limit per node
# Patterns from semantic seeds
patterns_semantic: List[List[str]] = field(default_factory=lambda: [
['semantic', 'semantic'], # topic expansion
['entity', 'temporal'], # entity timeline
['semantic', 'causes'], # reasoning chains (forward)
['semantic', 'caused_by'], # reasoning chains (backward)
['entity', 'semantic'], # entity context
])
# Patterns from temporal seeds
patterns_temporal: List[List[str]] = field(default_factory=lambda: [
['temporal', 'semantic'], # what was happening then
['temporal', 'entity'], # who was involved then
])
@dataclass
class SeedNode:
"""An entry point node with its initial score."""
node_id: str
score: float # initial mass (e.g., similarity score)
# -----------------------------------------------------------------------------
# Core Algorithm
# -----------------------------------------------------------------------------
def mpfp_traverse(
seeds: List[SeedNode],
pattern: List[str],
adjacency: TypedAdjacency,
config: MPFPConfig,
) -> PatternResult:
"""
Forward Push traversal following a meta-path pattern.
Args:
seeds: Entry point nodes with initial scores
pattern: Sequence of edge types to follow
adjacency: Typed adjacency structure
config: Algorithm parameters
Returns:
PatternResult with accumulated scores per node
"""
if not seeds:
return PatternResult(pattern=pattern, scores={})
scores: Dict[str, float] = {}
# Initialize frontier with seed masses (normalized)
total_seed_score = sum(s.score for s in seeds)
if total_seed_score == 0:
total_seed_score = len(seeds) # fallback to uniform
frontier: Dict[str, float] = {
s.node_id: s.score / total_seed_score for s in seeds
}
# Follow pattern hop by hop
for edge_type in pattern:
next_frontier: Dict[str, float] = {}
for node_id, mass in frontier.items():
if mass < config.threshold:
continue
# Keep α portion for this node
scores[node_id] = scores.get(node_id, 0) + config.alpha * mass
# Push (1-α) to neighbors
push_mass = (1 - config.alpha) * mass
neighbors = adjacency.get_normalized_neighbors(
edge_type, node_id, config.top_k_neighbors
)
for neighbor in neighbors:
next_frontier[neighbor.node_id] = (
next_frontier.get(neighbor.node_id, 0) +
push_mass * neighbor.weight
)
frontier = next_frontier
# Final frontier nodes get their remaining mass
for node_id, mass in frontier.items():
if mass >= config.threshold:
scores[node_id] = scores.get(node_id, 0) + mass
return PatternResult(pattern=pattern, scores=scores)
def rrf_fusion(
results: List[PatternResult],
k: int = 60,
top_k: int = 50,
) -> List[Tuple[str, float]]:
"""
Reciprocal Rank Fusion to combine pattern results.
Args:
results: List of pattern results
k: RRF constant (higher = more uniform weighting)
top_k: Number of results to return
Returns:
List of (node_id, fused_score) tuples, sorted by score descending
"""
fused: Dict[str, float] = {}
for result in results:
if not result.scores:
continue
# Rank nodes by their score in this pattern
ranked = sorted(
result.scores.keys(),
key=lambda n: result.scores[n],
reverse=True
)
for rank, node_id in enumerate(ranked):
fused[node_id] = fused.get(node_id, 0) + 1.0 / (k + rank + 1)
# Sort by fused score and return top-k
sorted_results = sorted(
fused.items(),
key=lambda x: x[1],
reverse=True
)
return sorted_results[:top_k]
# -----------------------------------------------------------------------------
# Database Loading
# -----------------------------------------------------------------------------
async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency:
"""
Load all edges for a bank, split by edge type.
Single query, then organize in-memory for fast traversal.
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
AND ml.weight >= 0.1
ORDER BY ml.from_unit_id, ml.weight DESC
""",
bank_id
)
graphs: Dict[str, Dict[str, List[EdgeTarget]]] = defaultdict(
lambda: defaultdict(list)
)
for row in rows:
from_id = str(row['from_unit_id'])
to_id = str(row['to_unit_id'])
link_type = row['link_type']
weight = row['weight']
graphs[link_type][from_id].append(
EdgeTarget(node_id=to_id, weight=weight)
)
return TypedAdjacency(graphs=dict(graphs))
async def fetch_memory_units_by_ids(
pool,
node_ids: List[str],
fact_type: str,
) -> List[RetrievalResult]:
"""Fetch full memory unit details for a list of node IDs."""
if not node_ids:
return []
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id
FROM memory_units
WHERE id = ANY($1::uuid[])
AND fact_type = $2
""",
node_ids,
fact_type
)
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
# -----------------------------------------------------------------------------
# Graph Retriever Implementation
# -----------------------------------------------------------------------------
class MPFPGraphRetriever(GraphRetriever):
"""
Graph retrieval using Meta-Path Forward Push.
Runs predefined patterns in parallel from semantic and temporal seeds,
then fuses results via RRF.
"""
def __init__(self, config: Optional[MPFPConfig] = None):
"""
Initialize MPFP retriever.
Args:
config: Algorithm configuration (uses defaults if None)
"""
self.config = config or MPFPConfig()
self._adjacency_cache: Dict[str, TypedAdjacency] = {}
@property
def name(self) -> str:
return "mpfp"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
"""
Retrieve facts using MPFP algorithm.
Args:
pool: Database connection pool
query_embedding_str: Query embedding (used for fallback seed finding)
bank_id: Memory bank ID
fact_type: Fact type to filter
budget: Maximum results to return
query_text: Original query text (optional)
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
Returns:
List of RetrievalResult with activation scores
"""
# Load typed adjacency (could cache per bank_id with TTL)
adjacency = await load_typed_adjacency(pool, bank_id)
# Convert seeds to SeedNode format
semantic_seed_nodes = self._convert_seeds(semantic_seeds, 'similarity')
temporal_seed_nodes = self._convert_seeds(temporal_seeds, 'temporal_score')
# If no semantic seeds provided, fall back to finding our own
if not semantic_seed_nodes:
semantic_seed_nodes = await self._find_semantic_seeds(
pool, query_embedding_str, bank_id, fact_type
)
# Run all patterns in parallel
tasks = []
# Patterns from semantic seeds
for pattern in self.config.patterns_semantic:
if semantic_seed_nodes:
tasks.append(
asyncio.to_thread(
mpfp_traverse,
semantic_seed_nodes,
pattern,
adjacency,
self.config,
)
)
# Patterns from temporal seeds
for pattern in self.config.patterns_temporal:
if temporal_seed_nodes:
tasks.append(
asyncio.to_thread(
mpfp_traverse,
temporal_seed_nodes,
pattern,
adjacency,
self.config,
)
)
if not tasks:
return []
# Gather pattern results
pattern_results = await asyncio.gather(*tasks)
# Fuse results
fused = rrf_fusion(pattern_results, top_k=budget)
if not fused:
return []
# Get top result IDs (don't exclude seeds - they may be highly relevant)
result_ids = [node_id for node_id, score in fused][:budget]
# Fetch full details
results = await fetch_memory_units_by_ids(pool, result_ids, fact_type)
# Add activation scores from fusion
score_map = {node_id: score for node_id, score in fused}
for result in results:
result.activation = score_map.get(result.id, 0.0)
# Sort by activation
results.sort(key=lambda r: r.activation or 0, reverse=True)
return results
def _convert_seeds(
self,
seeds: Optional[List[RetrievalResult]],
score_attr: str,
) -> List[SeedNode]:
"""Convert RetrievalResult seeds to SeedNode format."""
if not seeds:
return []
result = []
for seed in seeds:
score = getattr(seed, score_attr, None)
if score is None:
score = seed.activation or seed.similarity or 1.0
result.append(SeedNode(node_id=seed.id, score=score))
return result
async def _find_semantic_seeds(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
) -> List[SeedNode]:
"""Fallback: find semantic seeds via embedding search."""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
SELECT id, 1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str, bank_id, fact_type, threshold, limit
)
return [
SeedNode(node_id=str(r['id']), score=r['similarity'])
for r in rows
]
@@ -10,10 +10,8 @@ class CrossEncoderReranker:
"""
Neural reranking using a cross-encoder model.
Uses cross-encoder/ms-marco-MiniLM-L-6-v2 by default:
- Fast inference (~80ms for 100 pairs on CPU)
- Small model (80MB)
- Trained for passage re-ranking
Configured via environment variables (see cross_encoder.py).
Default local model is cross-encoder/ms-marco-MiniLM-L-6-v2.
"""
def __init__(self, cross_encoder=None):
@@ -21,14 +19,12 @@ class CrossEncoderReranker:
Initialize cross-encoder reranker.
Args:
cross_encoder: CrossEncoderReranker instance. If None, uses default
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
(loaded lazily for faster startup)
cross_encoder: CrossEncoderModel instance. If None, creates one from
environment variables (defaults to local provider)
"""
if cross_encoder is None:
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
# Model is loaded lazily - call ensure_loaded() during initialize()
cross_encoder = SentenceTransformersCrossEncoder()
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
cross_encoder = create_cross_encoder_from_env()
self.cross_encoder = cross_encoder
def rerank(
@@ -4,15 +4,61 @@ Retrieval module for 4-way parallel search.
Implements:
1. Semantic retrieval (vector similarity)
2. BM25 retrieval (keyword/full-text search)
3. Graph retrieval (spreading activation)
3. Graph retrieval (via pluggable GraphRetriever interface)
4. Temporal retrieval (time-aware search with spreading)
"""
from typing import List, Dict, Any, Tuple, Optional
from typing import List, Dict, Optional
from dataclasses import dataclass, field
from datetime import datetime
import asyncio
import logging
from ..db_utils import acquire_with_retry
from .types import RetrievalResult
from .graph_retrieval import GraphRetriever, BFSGraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from ...config import get_config
logger = logging.getLogger(__name__)
@dataclass
class ParallelRetrievalResult:
"""Result from parallel retrieval across all methods."""
semantic: List[RetrievalResult]
bm25: List[RetrievalResult]
graph: List[RetrievalResult]
temporal: Optional[List[RetrievalResult]]
timings: Dict[str, float] = field(default_factory=dict)
temporal_constraint: Optional[tuple] = None # (start_date, end_date)
# Default graph retriever instance (can be overridden)
_default_graph_retriever: Optional[GraphRetriever] = None
def get_default_graph_retriever() -> GraphRetriever:
"""Get or create the default graph retriever based on config."""
global _default_graph_retriever
if _default_graph_retriever is None:
config = get_config()
retriever_type = config.graph_retriever.lower()
if retriever_type == "mpfp":
_default_graph_retriever = MPFPGraphRetriever()
logger.info("Using MPFP graph retriever")
elif retriever_type == "bfs":
_default_graph_retriever = BFSGraphRetriever()
logger.info("Using BFS graph retriever")
else:
logger.warning(f"Unknown graph retriever '{retriever_type}', falling back to MPFP")
_default_graph_retriever = MPFPGraphRetriever()
return _default_graph_retriever
def set_default_graph_retriever(retriever: GraphRetriever) -> None:
"""Set the default graph retriever (for configuration/testing)."""
global _default_graph_retriever
_default_graph_retriever = retriever
async def retrieve_semantic(
@@ -105,121 +151,6 @@ async def retrieve_bm25(
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_graph(
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
budget: int
) -> List[RetrievalResult]:
"""
Graph retrieval via spreading activation.
Args:
conn: Database connection
query_emb_str: Query embedding as string
agent_id: bank ID
fact_type: Fact type to filter
budget: Node budget for graph traversal
Returns:
List of RetrievalResult objects
"""
# Find entry points
entry_points = await conn.fetch(
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= 0.5
ORDER BY embedding <=> $1::vector
LIMIT 5
""",
query_emb_str, bank_id, fact_type
)
if not entry_points:
return []
# BFS-style spreading activation with batched neighbor fetching
visited = set()
results = []
queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points]
budget_remaining = budget
# Process nodes in batches to reduce DB roundtrips
batch_size = 20 # Fetch neighbors for up to 20 nodes at once
while queue and budget_remaining > 0:
# Collect a batch of nodes to process
batch_nodes = []
batch_activations = {}
while queue and len(batch_nodes) < batch_size and budget_remaining > 0:
current, activation = queue.pop(0)
unit_id = current.id
if unit_id not in visited:
visited.add(unit_id)
budget_remaining -= 1
results.append(current)
batch_nodes.append(current.id)
batch_activations[unit_id] = activation
# Batch fetch neighbors for all nodes in this batch
# Fetch top weighted neighbors (batch_size * 20 = ~400 for good distribution)
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end, mu.mentioned_at,
mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.weight >= 0.1
AND mu.fact_type = $2
ORDER BY ml.weight DESC
LIMIT $3
""",
batch_nodes, fact_type, max_neighbors
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id not in visited:
# Get parent activation
parent_id = str(n["from_unit_id"])
activation = batch_activations.get(parent_id, 0.5)
# Boost activation for causal links (they're high-value relationships)
link_type = n["link_type"]
base_weight = n["weight"]
# Causal links get 1.5-2.0x boost depending on type
if link_type in ("causes", "caused_by"):
# Direct causation - very strong relationship
causal_boost = 2.0
elif link_type in ("enables", "prevents"):
# Conditional causation - strong but not as direct
causal_boost = 1.5
else:
# Temporal, semantic, entity links - standard weight
causal_boost = 1.0
effective_weight = base_weight * causal_boost
new_activation = activation * effective_weight * 0.8
if new_activation > 0.1:
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
return results
async def retrieve_temporal(
conn,
query_emb_str: str,
@@ -419,8 +350,9 @@ async def retrieve_parallel(
fact_type: str,
thinking_budget: int,
question_date: Optional[datetime] = None,
query_analyzer: Optional["QueryAnalyzer"] = None
) -> Tuple[List[RetrievalResult], List[RetrievalResult], List[RetrievalResult], Optional[List[RetrievalResult]], Dict[str, float], Optional[Tuple[datetime, datetime]]]:
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: Optional[GraphRetriever] = None,
) -> ParallelRetrievalResult:
"""
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
@@ -428,76 +360,318 @@ async def retrieve_parallel(
pool: Database connection pool
query_text: Query text
query_embedding_str: Query embedding as string
agent_id: bank ID
bank_id: Bank ID
fact_type: Fact type to filter
thinking_budget: Budget for graph traversal and retrieval limits
question_date: Optional date when question was asked (for temporal filtering)
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
graph_retriever: Graph retrieval strategy (defaults to configured retriever)
Returns:
Tuple of (semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint)
Each results list contains RetrievalResult objects
temporal_results is None if no temporal constraint detected
timings is a dict with per-method latencies in seconds
temporal_constraint is the (start_date, end_date) tuple if detected, else None
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
"""
# Detect temporal constraint
from .temporal_extraction import extract_temporal_constraint
import time
temporal_constraint = extract_temporal_constraint(
query_text, reference_date=question_date, analyzer=query_analyzer
)
# Wrapper to track timing for each retrieval method
async def timed_retrieval(name: str, coro):
start = time.time()
result = await coro
duration = time.time() - start
return result, name, duration
retriever = graph_retriever or get_default_graph_retriever()
async def run_semantic():
async with acquire_with_retry(pool) as conn:
return await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
async def run_bm25():
async with acquire_with_retry(pool) as conn:
return await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
async def run_graph():
async with acquire_with_retry(pool) as conn:
return await retrieve_graph(conn, query_embedding_str, bank_id, fact_type, budget=thinking_budget)
async def run_temporal(start_date, end_date):
async with acquire_with_retry(pool) as conn:
return await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
start_date, end_date, budget=thinking_budget, semantic_threshold=0.1
)
# Run retrievals in parallel with timing
timings = {}
if temporal_constraint:
start_date, end_date = temporal_constraint
results = await asyncio.gather(
timed_retrieval("semantic", run_semantic()),
timed_retrieval("bm25", run_bm25()),
timed_retrieval("graph", run_graph()),
timed_retrieval("temporal", run_temporal(start_date, end_date))
if retriever.name == "mpfp":
return await _retrieve_parallel_mpfp(
pool, query_text, query_embedding_str, bank_id, fact_type,
thinking_budget, temporal_constraint, retriever
)
semantic_results, _, timings["semantic"] = results[0]
bm25_results, _, timings["bm25"] = results[1]
graph_results, _, timings["graph"] = results[2]
temporal_results, _, timings["temporal"] = results[3]
else:
results = await asyncio.gather(
timed_retrieval("semantic", run_semantic()),
timed_retrieval("bm25", run_bm25()),
timed_retrieval("graph", run_graph())
return await _retrieve_parallel_bfs(
pool, query_text, query_embedding_str, bank_id, fact_type,
thinking_budget, temporal_constraint, retriever
)
semantic_results, _, timings["semantic"] = results[0]
bm25_results, _, timings["bm25"] = results[1]
graph_results, _, timings["graph"] = results[2]
temporal_results = None
return semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint
@dataclass
class _SemanticGraphResult:
"""Internal result from semantic→graph chain."""
semantic: List[RetrievalResult]
graph: List[RetrievalResult]
semantic_time: float
graph_time: float
@dataclass
class _TimedResult:
"""Internal result with timing."""
results: List[RetrievalResult]
time: float
async def _retrieve_parallel_mpfp(
pool,
query_text: str,
query_embedding_str: str,
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: Optional[tuple],
retriever: GraphRetriever,
) -> ParallelRetrievalResult:
"""
MPFP retrieval with optimized parallelization.
Runs 2-3 parallel task chains:
- Task 1: Semantic → Graph (chained, graph uses semantic seeds)
- Task 2: BM25 (independent)
- Task 3: Temporal (if constraint detected)
"""
import time
async def run_semantic_then_graph() -> _SemanticGraphResult:
"""Chain: semantic retrieval → graph retrieval (using semantic as seeds)."""
start = time.time()
async with acquire_with_retry(pool) as conn:
semantic = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget
)
semantic_time = time.time() - start
# Get temporal seeds if needed (quick query, part of this chain)
temporal_seeds = None
if temporal_constraint:
tc_start, tc_end = temporal_constraint
async with acquire_with_retry(pool) as conn:
temporal_seeds = await _get_temporal_entry_points(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, limit=20
)
# Run graph with seeds
start = time.time()
graph = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding_str,
bank_id=bank_id,
fact_type=fact_type,
budget=thinking_budget,
query_text=query_text,
semantic_seeds=semantic,
temporal_seeds=temporal_seeds,
)
graph_time = time.time() - start
return _SemanticGraphResult(semantic, graph, semantic_time, graph_time)
async def run_bm25() -> _TimedResult:
"""Independent BM25 retrieval."""
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
return _TimedResult(results, time.time() - start)
async def run_temporal(tc_start, tc_end) -> _TimedResult:
"""Temporal retrieval (uses its own entry point finding)."""
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, budget=thinking_budget, semantic_threshold=0.1
)
return _TimedResult(results, time.time() - start)
# Run parallel task chains
if temporal_constraint:
tc_start, tc_end = temporal_constraint
sg_result, bm25_result, temporal_result = await asyncio.gather(
run_semantic_then_graph(),
run_bm25(),
run_temporal(tc_start, tc_end),
)
return ParallelRetrievalResult(
semantic=sg_result.semantic,
bm25=bm25_result.results,
graph=sg_result.graph,
temporal=temporal_result.results,
timings={
"semantic": sg_result.semantic_time,
"graph": sg_result.graph_time,
"bm25": bm25_result.time,
"temporal": temporal_result.time,
},
temporal_constraint=temporal_constraint,
)
else:
sg_result, bm25_result = await asyncio.gather(
run_semantic_then_graph(),
run_bm25(),
)
return ParallelRetrievalResult(
semantic=sg_result.semantic,
bm25=bm25_result.results,
graph=sg_result.graph,
temporal=None,
timings={
"semantic": sg_result.semantic_time,
"graph": sg_result.graph_time,
"bm25": bm25_result.time,
},
temporal_constraint=None,
)
async def _get_temporal_entry_points(
conn,
query_embedding_str: str,
bank_id: str,
fact_type: str,
start_date: datetime,
end_date: datetime,
limit: int = 20,
semantic_threshold: float = 0.1,
) -> List[RetrievalResult]:
"""Get temporal entry points (facts in date range with semantic relevance)."""
from datetime import timezone
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=timezone.utc)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
rows = await conn.fetch(
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $5 AND occurred_end >= $4)
OR (mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
OR (occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
OR (occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC,
(embedding <=> $1::vector) ASC
LIMIT $7
""",
query_embedding_str, bank_id, fact_type, start_date, end_date, semantic_threshold, limit
)
results = []
total_days = max((end_date - start_date).total_seconds() / 86400, 1)
mid_date = start_date + (end_date - start_date) / 2
for row in rows:
result = RetrievalResult.from_db_row(dict(row))
# Calculate temporal proximity score
best_date = None
if row["occurred_start"] and row["occurred_end"]:
best_date = row["occurred_start"] + (row["occurred_end"] - row["occurred_start"]) / 2
elif row["occurred_start"]:
best_date = row["occurred_start"]
elif row["occurred_end"]:
best_date = row["occurred_end"]
elif row["mentioned_at"]:
best_date = row["mentioned_at"]
if best_date:
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
result.temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0)
else:
result.temporal_proximity = 0.5
result.temporal_score = result.temporal_proximity
results.append(result)
return results
async def _retrieve_parallel_bfs(
pool,
query_text: str,
query_embedding_str: str,
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: Optional[tuple],
retriever: GraphRetriever,
) -> ParallelRetrievalResult:
"""BFS retrieval: all methods run in parallel (original behavior)."""
import time
async def run_semantic() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
return _TimedResult(results, time.time() - start)
async def run_bm25() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
return _TimedResult(results, time.time() - start)
async def run_graph() -> _TimedResult:
start = time.time()
results = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding_str,
bank_id=bank_id,
fact_type=fact_type,
budget=thinking_budget,
query_text=query_text,
)
return _TimedResult(results, time.time() - start)
async def run_temporal(tc_start, tc_end) -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, budget=thinking_budget, semantic_threshold=0.1
)
return _TimedResult(results, time.time() - start)
if temporal_constraint:
tc_start, tc_end = temporal_constraint
semantic_r, bm25_r, graph_r, temporal_r = await asyncio.gather(
run_semantic(),
run_bm25(),
run_graph(),
run_temporal(tc_start, tc_end),
)
return ParallelRetrievalResult(
semantic=semantic_r.results,
bm25=bm25_r.results,
graph=graph_r.results,
temporal=temporal_r.results,
timings={
"semantic": semantic_r.time,
"bm25": bm25_r.time,
"graph": graph_r.time,
"temporal": temporal_r.time,
},
temporal_constraint=temporal_constraint,
)
else:
semantic_r, bm25_r, graph_r = await asyncio.gather(
run_semantic(),
run_bm25(),
run_graph(),
)
return ParallelRetrievalResult(
semantic=semantic_r.results,
bm25=bm25_r.results,
graph=graph_r.results,
temporal=None,
timings={
"semantic": semantic_r.time,
"bm25": bm25_r.time,
"graph": graph_r.time,
},
temporal_constraint=None,
)
@@ -108,6 +108,7 @@ class RetrievalResult(BaseModel):
class RetrievalMethodResults(BaseModel):
"""Results from a single retrieval method."""
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
fact_type: Optional[str] = Field(default=None, description="Fact type this retrieval was for (world, experience, opinion)")
results: List[RetrievalResult] = Field(description="Retrieved results with ranks")
duration_seconds: float = Field(description="Time taken for this retrieval")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
@@ -289,7 +289,8 @@ class SearchTracer:
results: List[tuple], # List of (doc_id, data) tuples
duration_seconds: float,
score_field: str, # e.g., "similarity", "bm25_score"
metadata: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None,
fact_type: Optional[str] = None
):
"""
Record results from a single retrieval method.
@@ -300,6 +301,7 @@ class SearchTracer:
duration_seconds: Time taken for this retrieval
score_field: Field name containing the score in data dict
metadata: Optional metadata about this retrieval method
fact_type: Fact type this retrieval was for (world, experience, opinion)
"""
retrieval_results = []
for rank, (doc_id, data) in enumerate(results, start=1):
@@ -313,7 +315,7 @@ class SearchTracer:
text=data.get("text", ""),
context=data.get("context", ""),
event_date=data.get("event_date"),
fact_type=data.get("fact_type"),
fact_type=data.get("fact_type") or fact_type,
score=score,
score_name=score_field,
)
@@ -322,6 +324,7 @@ class SearchTracer:
self.retrieval_results.append(
RetrievalMethodResults(
method_name=method_name,
fact_type=fact_type,
results=retrieval_results,
duration_seconds=duration_seconds,
metadata=metadata or {},
@@ -367,8 +370,10 @@ class SearchTracer:
rank_change = rrf_rank - rank # Positive = moved up
# Extract score components (only include non-None values)
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, combined_score, weight
score_components = {}
for key in ["semantic_similarity", "bm25_score", "rrf_score", "recency_normalized", "frequency_normalized"]:
for key in ["cross_encoder_score", "cross_encoder_score_normalized", "rrf_score", "rrf_normalized", "temporal", "recency", "combined_score"]:
if key in result and result[key] is not None:
score_components[key] = result[key]
@@ -31,8 +31,9 @@ class RetrievalResult:
embedding: Optional[List[float]] = None
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: Optional[float] = None # Semantic/graph retrieval
similarity: Optional[float] = None # Semantic retrieval
bm25_score: Optional[float] = None # BM25 retrieval
activation: Optional[float] = None # Graph retrieval (spreading activation)
temporal_score: Optional[float] = None # Temporal retrieval
temporal_proximity: Optional[float] = None # Temporal retrieval
@@ -54,6 +55,7 @@ class RetrievalResult:
embedding=row.get("embedding"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
temporal_score=row.get("temporal_score"),
temporal_proximity=row.get("temporal_proximity"),
)
@@ -152,6 +154,7 @@ class ScoredResult:
result["cross_encoder_score"] = self.cross_encoder_score
result["cross_encoder_score_normalized"] = self.cross_encoder_score_normalized
result["rrf_normalized"] = self.rrf_normalized
result["temporal"] = self.temporal
result["recency"] = self.recency
result["combined_score"] = self.combined_score
result["weight"] = self.weight
+209
View File
@@ -0,0 +1,209 @@
"""
Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
import os
import signal
import sys
import warnings
from typing import Optional
import uvicorn
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")
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: Optional[MemoryEngine] = None
def _cleanup():
"""Synchronous cleanup function to stop resources on exit."""
global _memory
if _memory is not None and _memory._pg0 is not None:
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(_memory._pg0.stop())
loop.close()
print("\npg0 stopped.")
except Exception as e:
print(f"\nError stopping pg0: {e}")
def _signal_handler(signum, frame):
"""Handle SIGINT/SIGTERM to ensure cleanup."""
print(f"\nReceived signal {signum}, shutting down...")
_cleanup()
sys.exit(0)
def main():
"""Main entry point for the CLI."""
global _memory
# Load configuration from environment (for CLI args defaults)
config = get_config()
parser = argparse.ArgumentParser(
prog="hindsight-api",
description="Hindsight API Server",
)
# Server options
parser.add_argument(
"--host", default=config.host,
help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
)
parser.add_argument(
"--port", type=int, default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)"
)
parser.add_argument(
"--log-level", default=config.log_level,
choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)"
)
# Development options
parser.add_argument(
"--reload", action="store_true",
help="Enable auto-reload on code changes (development only)"
)
parser.add_argument(
"--workers", type=int, default=1,
help="Number of worker processes (default: 1)"
)
# Access log options
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
parser.add_argument(
"--no-access-log", dest="access_log", action="store_false",
help="Disable access log (default)"
)
parser.set_defaults(access_log=False)
# Proxy options
parser.add_argument(
"--proxy-headers", action="store_true",
help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
)
parser.add_argument(
"--forwarded-allow-ips", default=None,
help="Comma separated list of IPs to trust with proxy headers"
)
# SSL options
parser.add_argument(
"--ssl-keyfile", default=None,
help="SSL key file"
)
parser.add_argument(
"--ssl-certfile", default=None,
help="SSL certificate file"
)
args = parser.parse_args()
# Configure Python logging based on log level
# Update config with CLI override if provided
if args.log_level != config.log_level:
config = HindsightConfig(
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_tei_url=config.reranker_tei_url,
host=args.host,
port=args.port,
log_level=args.log_level,
mcp_enabled=config.mcp_enabled,
)
config.configure_logging()
# Register cleanup handlers
atexit.register(_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Create MemoryEngine (reads configuration from environment)
_memory = MemoryEngine()
# Create FastAPI app
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp",
initialize_memory=True,
)
# Prepare uvicorn config
uvicorn_config = {
"app": app,
"host": args.host,
"port": args.port,
"log_level": args.log_level,
"access_log": args.access_log,
"proxy_headers": args.proxy_headers,
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
}
# Add optional parameters if provided
if args.reload:
uvicorn_config["reload"] = True
if args.workers > 1:
uvicorn_config["workers"] = args.workers
if args.forwarded_allow_ips:
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
if args.ssl_keyfile:
uvicorn_config["ssl_keyfile"] = args.ssl_keyfile
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
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)
if __name__ == "__main__":
main()
+7 -7
View File
@@ -88,11 +88,11 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
try:
# Determine script location
if script_location is None:
# Default: use the alembic directory in the hindsight_api package
# This file is in: hindsight-api/hindsight_api/migrations.py
# Default location is: hindsight-api/alembic
package_root = Path(__file__).parent.parent
script_location = str(package_root / "alembic")
# Default: use the alembic directory inside the hindsight_api package
# This file is in: hindsight_api/migrations.py
# Alembic is in: hindsight_api/alembic/
package_dir = Path(__file__).parent
script_location = str(package_dir / "alembic")
script_path = Path(script_location)
if not script_path.exists():
@@ -162,8 +162,8 @@ def check_migration_status(database_url: Optional[str] = None, script_location:
# Get head revision from migration scripts
if script_location is None:
package_root = Path(__file__).parent.parent
script_location = str(package_root / "alembic")
package_dir = Path(__file__).parent
script_location = str(package_dir / "alembic")
script_path = Path(script_location)
if not script_path.exists():
+56 -327
View File
@@ -1,372 +1,116 @@
import asyncio
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
from pathlib import Path
from typing import Optional
import httpx
from pg0 import Pg0
logger = logging.getLogger(__name__)
# pg0 configuration
BINARY_NAME = "pg0"
DEFAULT_PORT = 5555
DEFAULT_USERNAME = "hindsight"
DEFAULT_PASSWORD = "hindsight"
DEFAULT_DATABASE = "hindsight"
def get_platform_binary_name() -> str:
"""Get the appropriate binary name for the current platform.
Supported platforms:
- macOS ARM64 (darwin-aarch64)
- Linux x86_64 (gnu)
- Linux ARM64 (gnu)
- Windows x86_64
"""
system = platform.system().lower()
machine = platform.machine().lower()
# Normalize architecture names
if machine in ("x86_64", "amd64"):
arch = "x86_64"
elif machine in ("arm64", "aarch64"):
arch = "aarch64"
else:
raise RuntimeError(
f"Embedded PostgreSQL is not supported on architecture: {machine}. "
f"Supported architectures: x86_64/amd64 (Linux, Windows), aarch64/arm64 (macOS, Linux)"
)
if system == "darwin" and arch == "aarch64":
return "pg0-darwin-aarch64"
elif system == "linux" and arch == "x86_64":
return "pg0-linux-x86_64-gnu"
elif system == "linux" and arch == "aarch64":
return "pg0-linux-aarch64-gnu"
elif system == "windows" and arch == "x86_64":
return "pg0-windows-x86_64.exe"
else:
raise RuntimeError(
f"Embedded PostgreSQL is not supported on {system}-{arch}. "
f"Supported platforms: darwin-aarch64 (macOS ARM), linux-x86_64-gnu, linux-aarch64-gnu, windows-x86_64"
)
def get_download_url(
version: str = "latest",
repo: str = "vectorize-io/pg0",
) -> str:
"""Get the download URL for pg0 binary."""
binary_name = get_platform_binary_name()
if version == "latest":
return f"https://github.com/{repo}/releases/latest/download/{binary_name}"
else:
return f"https://github.com/{repo}/releases/download/{version}/{binary_name}"
def _find_pg0_binary() -> Optional[Path]:
"""Find pg0 binary in PATH or default install location."""
# First check PATH
pg0_in_path = shutil.which("pg0")
if pg0_in_path:
return Path(pg0_in_path)
# Fall back to default install location
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists() and os.access(default_path, os.X_OK):
return default_path
return None
class EmbeddedPostgres:
"""
Manages an embedded PostgreSQL server instance using pg0.
This class handles:
- Finding or downloading the pg0 CLI
- Starting/stopping the PostgreSQL server
- Getting the connection URI
Example:
pg = EmbeddedPostgres()
await pg.ensure_installed()
await pg.start()
uri = await pg.get_uri()
# ... use uri with asyncpg ...
await pg.stop()
"""
"""Manages an embedded PostgreSQL server instance using pg0-embedded."""
def __init__(
self,
version: str = "latest",
port: int = DEFAULT_PORT,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
database: str = DEFAULT_DATABASE,
name: str = "hindsight",
**kwargs,
):
"""
Initialize the embedded PostgreSQL manager.
Args:
version: Version of pg0 to download if not found. Defaults to "latest"
port: Port to listen on. Defaults to 5555
username: Username for the database. Defaults to "hindsight"
password: Password for the database. Defaults to "hindsight"
database: Database name to create. Defaults to "hindsight"
name: Instance name for pg0. Defaults to "hindsight"
"""
self.version = version
self.port = port
self.username = username
self.password = password
self.database = database
self.name = name
self._pg0: Optional[Pg0] = None
# Will be set when binary is found/installed
self._binary_path: Optional[Path] = _find_pg0_binary()
@property
def binary_path(self) -> Path:
"""Get the path to the pg0 binary."""
if self._binary_path is None:
# Default install location
return Path.home() / ".hindsight" / "bin" / "pg0"
return self._binary_path
def is_installed(self) -> bool:
"""Check if pg0 is available (in PATH or installed)."""
self._binary_path = _find_pg0_binary()
return self._binary_path is not None
async def ensure_installed(self) -> None:
"""
Ensure pg0 is available.
Checks PATH and default location. If not found, raises an error
instructing the user to install pg0 manually.
"""
if self.is_installed():
logger.debug(f"pg0 found at {self._binary_path}")
return
raise RuntimeError(
"pg0 is not installed. Please install it manually:\n"
" curl -fsSL https://github.com/vectorize-io/pg0/releases/latest/download/pg0-linux-amd64 -o ~/.local/bin/pg0 && chmod +x ~/.local/bin/pg0\n"
"Or visit: https://github.com/vectorize-io/pg0/releases"
)
def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess:
"""Run a pg0 command synchronously."""
cmd = [str(self.binary_path), *args]
return subprocess.run(cmd, capture_output=capture_output, text=True)
async def _run_command_async(self, *args: str, timeout: int = 120) -> tuple[int, str, str]:
"""Run a pg0 command asynchronously."""
cmd = [str(self.binary_path), *args]
def run_sync():
try:
result = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return 1, "", "Command timed out"
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, run_sync)
def _extract_uri_from_output(self, output: str) -> Optional[str]:
"""Extract the PostgreSQL URI from pg0 start output."""
match = re.search(r"Connection URI:\s*(postgresql://[^\s]+)", output)
if match:
return match.group(1)
return None
async def _get_version(self) -> str:
"""Get the pg0 version."""
returncode, stdout, stderr = await self._run_command_async("--version", timeout=10)
if returncode == 0 and stdout:
return stdout.strip()
return "unknown"
def _get_pg0(self) -> Pg0:
if self._pg0 is None:
self._pg0 = Pg0(
name=self.name,
port=self.port,
username=self.username,
password=self.password,
database=self.database,
)
return self._pg0
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
"""
Start the PostgreSQL server with retry logic.
Args:
max_retries: Maximum number of start attempts (default: 3)
retry_delay: Initial delay between retries in seconds (default: 2.0)
Returns:
The connection URI for the started server.
Raises:
RuntimeError: If the server fails to start after all retries.
"""
if not self.is_installed():
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
# Log pg0 version
version = await self._get_version()
logger.info(f"Starting embedded PostgreSQL with pg0 {version} (name: {self.name}, port: {self.port})...")
"""Start the PostgreSQL server with retry logic."""
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
pg0 = self._get_pg0()
last_error = None
for attempt in range(1, max_retries + 1):
returncode, stdout, stderr = await self._run_command_async(
"start",
"--name", self.name,
"--port", str(self.port),
"--username", self.username,
"--password", self.password,
"--database", self.database,
timeout=300,
)
# Try to extract URI from output
uri = self._extract_uri_from_output(stdout)
if uri:
logger.info(f"PostgreSQL started on port {self.port}")
return uri
# Check if pg0 info can find the running instance
try:
uri = await self.get_uri()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.start)
logger.info(f"PostgreSQL started on port {self.port}")
# Construct URI manually since pg0-embedded may return None
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
return uri
except RuntimeError:
pass
except Exception as e:
last_error = str(e)
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}")
logger.debug(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}")
# Start failed, log and retry
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...")
await asyncio.sleep(delay)
else:
logger.warning(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
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. "
f"Last error: {last_error}"
)
async def stop(self) -> None:
"""Stop the PostgreSQL server."""
if not self.is_installed():
return
pg0 = self._get_pg0()
logger.info(f"Stopping embedded PostgreSQL (name: {self.name})...")
returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name)
if returncode != 0:
if "not running" in stderr.lower():
return
raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}")
logger.info("Embedded PostgreSQL stopped")
async def _get_info(self) -> dict:
"""Get info from pg0 using the `info -o json` command."""
if not self.is_installed():
raise RuntimeError("pg0 is not installed.")
returncode, stdout, stderr = await self._run_command_async(
"info", "--name", self.name, "-o", "json"
)
if returncode != 0:
raise RuntimeError(f"Failed to get PostgreSQL info: {stderr}")
try:
return json.loads(stdout.strip())
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse pg0 info output: {e}")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pg0.stop)
logger.info("Embedded PostgreSQL stopped")
except Exception as e:
if "not running" in str(e).lower():
return
raise RuntimeError(f"Failed to stop PostgreSQL: {e}")
async def get_uri(self) -> str:
"""Get the connection URI for the PostgreSQL server."""
info = await self._get_info()
uri = info.get("uri")
if not uri:
raise RuntimeError("PostgreSQL server is not running or URI not available")
pg0 = self._get_pg0()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.info)
# Construct URI manually since pg0-embedded may return None
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
return uri
async def status(self) -> dict:
"""Get the status of the PostgreSQL server."""
if not self.is_installed():
return {"installed": False, "running": False}
try:
info = await self._get_info()
return {
"installed": True,
"running": info.get("running", False),
"uri": info.get("uri"),
}
except RuntimeError:
return {"installed": True, "running": False}
async def is_running(self) -> bool:
"""Check if the PostgreSQL server is currently running."""
if not self.is_installed():
return False
try:
info = await self._get_info()
return info.get("running", False)
except RuntimeError:
pg0 = self._get_pg0()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.info)
return info is not None and info.running
except Exception:
return False
async def ensure_running(self) -> str:
"""
Ensure the PostgreSQL server is running.
Installs if needed, starts if not running.
Returns:
The connection URI.
"""
await self.ensure_installed()
"""Ensure the PostgreSQL server is running, starting it if needed."""
if await self.is_running():
return await self.get_uri()
return await self.start()
def uninstall(self) -> None:
"""Remove the pg0 binary (only if we installed it)."""
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists():
default_path.unlink()
logger.info(f"Removed {default_path}")
def clear_data(self) -> None:
"""Remove all PostgreSQL data (destructive!)."""
result = self._run_command("drop", "--name", self.name, "--force")
if result.returncode == 0:
logger.info(f"Dropped pg0 instance {self.name}")
else:
logger.warning(f"Failed to drop pg0 instance {self.name}: {result.stderr}")
# Convenience functions
_default_instance: Optional[EmbeddedPostgres] = None
@@ -374,33 +118,18 @@ _default_instance: Optional[EmbeddedPostgres] = None
def get_embedded_postgres() -> EmbeddedPostgres:
"""Get or create the default EmbeddedPostgres instance."""
global _default_instance
if _default_instance is None:
_default_instance = EmbeddedPostgres()
return _default_instance
async def start_embedded_postgres() -> str:
"""
Quick start function for embedded PostgreSQL.
Downloads, installs, and starts PostgreSQL in one call.
Returns:
Connection URI string
Example:
db_url = await start_embedded_postgres()
conn = await asyncpg.connect(db_url)
"""
pg = get_embedded_postgres()
return await pg.ensure_running()
"""Quick start function for embedded PostgreSQL."""
return await get_embedded_postgres().ensure_running()
async def stop_embedded_postgres() -> None:
"""Stop the default embedded PostgreSQL instance."""
global _default_instance
if _default_instance:
await _default_instance.stop()
+43
View File
@@ -0,0 +1,43 @@
"""
FastAPI server for Hindsight API.
This module provides the ASGI app for uvicorn import string usage:
uvicorn hindsight_api.server:app
For CLI usage, use the hindsight-api command instead.
"""
import os
import warnings
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
from hindsight_api.config import get_config
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Load configuration and configure logging
config = get_config()
config.configure_logging()
# Create app at module level (required for uvicorn import string)
# MemoryEngine reads configuration from environment variables automatically
_memory = MemoryEngine()
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":
# When run directly, delegate to the CLI
from hindsight_api.main import main
main()
@@ -1,12 +0,0 @@
"""
Web interface for memory system.
Provides FastAPI app and visualization interface.
"""
from hindsight_api.api import create_app
# Note: Don't import app from .server here to avoid circular import warnings
# when running with `python -m hindsight_api.web.server`
# If you need the app, import it directly: from hindsight_api.web.server import app
__all__ = ["create_app"]
-109
View File
@@ -1,109 +0,0 @@
"""
FastAPI server for memory graph visualization and API.
Provides REST API endpoints for memory operations and serves
the interactive visualization interface.
"""
import warnings
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
import logging
import os
import argparse
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Create app at module level (required for uvicorn import string)
_memory = MemoryEngine(
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0"),
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
)
# Check if MCP should be enabled
mcp_enabled = os.getenv("HINDSIGHT_API_MCP_ENABLED", "true").lower() == "true"
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":
import uvicorn
# Get log level from environment variable (default: info)
env_log_level = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
if env_log_level not in ["critical", "error", "warning", "info", "debug", "trace"]:
env_log_level = "info"
# Parse CLI arguments
parser = argparse.ArgumentParser(description="Hindsight API Server")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8888, help="Port to bind to (default: 8888)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes")
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
parser.add_argument("--log-level", default=env_log_level, choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {env_log_level}, from HINDSIGHT_API_LOG_LEVEL)")
parser.add_argument("--access-log", action="store_true", help="Enable access log")
parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log")
parser.add_argument("--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers")
parser.add_argument("--forwarded-allow-ips", default=None, help="Comma separated list of IPs to trust with proxy headers")
parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
parser.set_defaults(access_log=False)
args = parser.parse_args()
# Configure Python logging based on log level
log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
}
logging.basicConfig(
level=log_level_map.get(args.log_level, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
logging.info(f"Starting Hindsight API on {args.host}:{args.port}")
app_ref = "hindsight_api.web.server:app"
# Prepare uvicorn config
uvicorn_config = {
"app": app_ref,
"host": args.host,
"port": args.port,
"reload": args.reload,
"workers": args.workers,
"log_level": args.log_level,
"access_log": args.access_log,
"proxy_headers": args.proxy_headers,
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
}
# Add optional parameters if provided
if args.forwarded_allow_ips:
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
if args.ssl_keyfile:
uvicorn_config["ssl_keyfile"] = args.ssl_keyfile
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
uvicorn.run(**uvicorn_config)
+21 -6
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.1.0"
version = "0.1.5"
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,11 +24,12 @@ 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",
"fastmcp>=2.3.0",
"pg0-embedded>=0.1.0",
"python-dateutil>=2.8.0",
"opentelemetry-api>=1.20.0",
"opentelemetry-sdk>=1.20.0",
@@ -48,11 +49,25 @@ test = [
]
[project.scripts]
hindsight-api = "hindsight_api.cli:main"
hindsight-api = "hindsight_api.main:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_api"]
[tool.hatch.build.targets.wheel.sources]
"hindsight_api" = "hindsight_api"
[tool.hatch.build.targets.sdist]
include = [
"hindsight_api/**/*",
]
[tool.hatch.build]
include = [
"hindsight_api/**/*.py",
"hindsight_api/alembic/**/*",
]
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"
-46
View File
@@ -1,46 +0,0 @@
"""
Debug script to test chunk extraction.
"""
import asyncio
from datetime import datetime
from hindsight_api.engine.utils import extract_facts
from hindsight_api.engine.llm_wrapper import LLMConfig
import os
async def main():
# Set up LLM config
llm_config = LLMConfig.for_memory()
# Test content
long_content = """
Alice is a senior software engineer at TechCorp. She has been working there for 5 years.
Alice specializes in distributed systems and has led the development of the company's
microservices architecture. She is known for writing clean, well-documented code.
Bob joined the team last month as a junior developer. He is learning React and Node.js.
Bob is enthusiastic and asks great questions during code reviews. He recently completed
his first feature, which was a user authentication flow.
The team uses Kubernetes for container orchestration and deploys to AWS. They follow
agile methodologies with two-week sprints. Code reviews are mandatory before merging.
"""
# Extract facts and chunks
facts, chunks = await extract_facts(
text=long_content,
event_date=datetime(2024, 1, 15),
context="team overview",
llm_config=llm_config
)
print(f"\n=== Extracted {len(facts)} facts ===")
for i, fact in enumerate(facts):
print(f"{i+1}. {fact.fact[:100]}...")
print(f"\n=== Extracted {len(chunks)} chunks ===")
for i, (chunk_text, fact_count) in enumerate(chunks):
print(f"Chunk {i}: {fact_count} facts, {len(chunk_text)} chars")
print(f" Text: {chunk_text[:100]}...")
if __name__ == "__main__":
asyncio.run(main())
-69
View File
@@ -1,69 +0,0 @@
"""Test to verify mentioned_at uses event_date, not now()"""
import asyncio
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import MemoryEngine
async def test_mentioned_at_uses_event_date():
"""Verify that mentioned_at is set to event_date, not now()"""
# Use a date that's clearly not "now"
past_date = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
memory = MemoryEngine()
await memory.initialize()
try:
bank_id = "test_mentioned_at_debug"
# Store with explicit past event_date
unit_ids = await memory.retain_async(
bank_id=bank_id,
content="Alex went to the store.",
context="test",
event_date=past_date
)
print(f"\n✅ Stored {len(unit_ids)} units")
# Recall and check mentioned_at
result = await memory.recall_async(
bank_id=bank_id,
query="store",
max_tokens=500
)
print(f"✅ Found {len(result.results)} facts")
for i, fact in enumerate(result.results, 1):
print(f"\nFact {i}:")
print(f" Text: {fact.text[:80]}...")
print(f" mentioned_at: {fact.mentioned_at}")
print(f" occurred_start: {fact.occurred_start}")
# Parse mentioned_at
if isinstance(fact.mentioned_at, str):
mentioned_dt = datetime.fromisoformat(fact.mentioned_at.replace('Z', '+00:00'))
else:
mentioned_dt = fact.mentioned_at
# Check if mentioned_at matches our event_date
time_diff = abs((mentioned_dt - past_date).total_seconds())
if time_diff < 60:
print(f" ✅ mentioned_at correctly set to event_date")
else:
print(f" ❌ mentioned_at is {mentioned_dt}, expected {past_date}")
print(f" Time difference: {time_diff} seconds")
# Check if it's close to now()
now_diff = abs((mentioned_dt - datetime.now(timezone.utc)).total_seconds())
if now_diff < 60:
print(f" ⚠️ mentioned_at is using now() instead of event_date!")
await memory.delete_bank(bank_id)
finally:
await memory.close()
if __name__ == "__main__":
asyncio.run(test_mentioned_at_uses_event_date())
+4 -4
View File
@@ -8,9 +8,9 @@ import os
import filelock
from pathlib import Path
from dotenv import load_dotenv
from hindsight_api import MemoryEngine, LLMConfig, SentenceTransformersEmbeddings
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.pg0 import EmbeddedPostgres
@@ -111,14 +111,14 @@ def llm_config():
@pytest.fixture(scope="session")
def embeddings():
return SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
return LocalSTEmbeddings()
@pytest.fixture(scope="session")
def cross_encoder():
return SentenceTransformersCrossEncoder()
return LocalSTCrossEncoder()
@pytest.fixture(scope="session")
def query_analyzer():
@@ -0,0 +1,323 @@
"""
Tests for combined scoring functionality.
Verifies that:
1. RRF scores are properly normalized to [0, 1] range
2. Combined scoring formula is applied correctly
3. Tracer captures normalized values (not raw values)
"""
import pytest
from datetime import datetime, timezone
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult
from hindsight_api.engine.memory_engine import Budget
class TestRRFNormalization:
"""Test that RRF scores are properly normalized."""
def test_rrf_normalized_range(self):
"""RRF normalized values should be in [0, 1] range, not raw [0.04, 0.06]."""
# Simulate RRF scores like what we get from actual retrieval
raw_rrf_scores = [0.0607, 0.0550, 0.0480, 0.0390]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5
normalized.append(norm)
# Verify normalized values are in [0, 1]
for i, norm in enumerate(normalized):
assert 0.0 <= norm <= 1.0, f"Normalized RRF {norm} not in [0, 1] for raw {raw_rrf_scores[i]}"
# Highest raw should be 1.0
assert normalized[0] == 1.0, f"Highest RRF should normalize to 1.0, got {normalized[0]}"
# Lowest raw should be 0.0
assert normalized[-1] == 0.0, f"Lowest RRF should normalize to 0.0, got {normalized[-1]}"
def test_rrf_all_same_scores(self):
"""When all RRF scores are the same, normalized should be 0.5 (neutral)."""
raw_rrf_scores = [0.0500, 0.0500, 0.0500]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5 # Neutral value when all same
normalized.append(norm)
# All should be 0.5 when scores are identical
for norm in normalized:
assert norm == 0.5, f"Expected 0.5 for identical scores, got {norm}"
class TestCombinedScoringFormula:
"""Test that the combined scoring formula is applied correctly."""
def test_combined_score_calculation(self):
"""Verify the weighted combination: 0.6*CE + 0.2*RRF + 0.1*temporal + 0.1*recency."""
# Test case 1: All components at 1.0
ce_norm = 1.0
rrf_norm = 1.0
temporal = 1.0
recency = 1.0
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 1.0, f"All 1.0 should give 1.0, got {expected}"
# Test case 2: All components at 0.0
ce_norm = 0.0
rrf_norm = 0.0
temporal = 0.0
recency = 0.0
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 0.0, f"All 0.0 should give 0.0, got {expected}"
# Test case 3: High CE, low RRF (cross-encoder finds something retrieval missed)
ce_norm = 0.999
rrf_norm = 0.0 # Lowest in set
temporal = 0.5
recency = 0.5
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.5994 + 0.0 + 0.05 + 0.05 = 0.6994
assert abs(expected - 0.6994) < 0.001, f"Expected ~0.6994, got {expected}"
# Test case 4: Medium CE, high RRF (retrieval consensus)
ce_norm = 0.8
rrf_norm = 1.0 # Highest in set
temporal = 0.5
recency = 0.5
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.48 + 0.2 + 0.05 + 0.05 = 0.78
assert abs(expected - 0.78) < 0.001, f"Expected ~0.78, got {expected}"
def test_rrf_contribution_is_significant(self):
"""Verify RRF actually contributes to the final score (not negligible)."""
# Same CE, different RRF
ce_norm = 0.8
temporal = 0.5
recency = 0.5
# Low RRF
score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency
# High RRF
score_high_rrf = 0.6 * ce_norm + 0.2 * 1.0 + 0.1 * temporal + 0.1 * recency
# Difference should be 0.2 (20% contribution)
diff = score_high_rrf - score_low_rrf
assert abs(diff - 0.2) < 0.001, f"RRF should contribute 0.2 difference, got {diff}"
@pytest.mark.asyncio
async def test_trace_has_normalized_rrf(memory):
"""Integration test: verify trace contains normalized RRF values, not raw."""
bank_id = f"test_scoring_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple memories to ensure different RRF scores
await memory.retain_async(
bank_id=bank_id,
content="Python is a programming language created by Guido van Rossum",
context="tech facts",
)
await memory.retain_async(
bank_id=bank_id,
content="JavaScript was created by Brendan Eich at Netscape",
context="tech facts",
)
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is located in Paris, France",
context="geography facts",
)
await memory.retain_async(
bank_id=bank_id,
content="Mount Everest is the tallest mountain on Earth",
context="geography facts",
)
# Search with tracing
result = await memory.recall_async(
bank_id=bank_id,
query="programming languages",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=1024,
enable_trace=True,
)
assert result.trace is not None, "Trace should be present"
trace = result.trace
# Check reranked results have proper score_components
assert "reranked" in trace, "Trace should have reranked results"
assert len(trace["reranked"]) > 0, "Should have reranked results"
has_valid_rrf = False
has_valid_temporal = False
has_valid_recency = False
for r in trace["reranked"]:
sc = r.get("score_components", {})
# Check RRF normalized is present and in valid range
if "rrf_normalized" in sc:
rrf_norm = sc["rrf_normalized"]
assert 0.0 <= rrf_norm <= 1.0, f"rrf_normalized {rrf_norm} should be in [0, 1]"
# Should NOT be raw RRF score (which would be ~0.04-0.06)
# A normalized value of exactly 0.0 or 1.0 is valid (min/max of set)
# But raw scores like 0.0607 should never appear as normalized
if rrf_norm > 0.1: # Any value > 0.1 is likely properly normalized
has_valid_rrf = True
# Check temporal is present and in valid range
if "temporal" in sc:
temporal = sc["temporal"]
assert 0.0 <= temporal <= 1.0, f"temporal {temporal} should be in [0, 1]"
has_valid_temporal = True
# Check recency is present and in valid range
if "recency" in sc:
recency = sc["recency"]
assert 0.0 <= recency <= 1.0, f"recency {recency} should be in [0, 1]"
has_valid_recency = True
# At least some results should have these components
# (might not have rrf > 0.1 if all scores are same, which is fine)
assert has_valid_temporal, "Should have temporal scores in trace"
assert has_valid_recency, "Should have recency scores in trace"
print("\n✓ Combined scoring trace test passed!")
print(f" - Reranked results: {len(trace['reranked'])}")
if trace["reranked"]:
sc = trace["reranked"][0].get("score_components", {})
print(f" - First result score components: {sc}")
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_rrf_normalized_not_raw_in_trace(memory):
"""Verify that raw RRF scores (0.04-0.06 range) don't appear as normalized values."""
bank_id = f"test_rrf_raw_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough memories to get varied RRF scores
for i in range(5):
await memory.retain_async(
bank_id=bank_id,
content=f"Test fact number {i} about various topics",
context="test context",
)
result = await memory.recall_async(
bank_id=bank_id,
query="test fact",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
)
trace = result.trace
assert trace is not None
# Check that rrf_normalized values are NOT in the raw range
raw_rrf_range = (0.01, 0.08) # Raw RRF scores are typically in this range
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
if "rrf_normalized" in sc and "rrf_score" in sc:
rrf_norm = sc["rrf_normalized"]
rrf_raw = sc["rrf_score"]
# Raw should be in the typical range
assert raw_rrf_range[0] <= rrf_raw <= raw_rrf_range[1], \
f"Raw RRF {rrf_raw} should be in typical range {raw_rrf_range}"
# Normalized should either be:
# - 0.0 (min in set)
# - 1.0 (max in set)
# - 0.5 (all same)
# - Something in between (0.0 to 1.0)
# But NOT the same as raw (which would indicate no normalization)
if len(trace["reranked"]) > 1:
# If we have multiple results, normalized should differ from raw
# (unless by coincidence, which is very unlikely)
assert rrf_norm != rrf_raw, \
f"Normalized RRF ({rrf_norm}) should differ from raw ({rrf_raw})"
print("\n✓ RRF raw vs normalized test passed!")
finally:
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_combined_score_matches_components(memory):
"""Verify the final score actually equals the weighted sum of components."""
bank_id = f"test_combined_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="The quick brown fox jumps over the lazy dog",
context="test",
)
await memory.retain_async(
bank_id=bank_id,
content="A quick test of the emergency broadcast system",
context="test",
)
result = await memory.recall_async(
bank_id=bank_id,
query="quick test",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
)
trace = result.trace
assert trace is not None
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
final_score = r.get("rerank_score", 0)
# Get components (use defaults if missing)
ce = sc.get("cross_encoder_score_normalized", 0)
rrf = sc.get("rrf_normalized", 0.5)
tmp = sc.get("temporal", 0.5)
rec = sc.get("recency", 0.5)
# Calculate expected score
expected = 0.6 * ce + 0.2 * rrf + 0.1 * tmp + 0.1 * rec
# Allow small floating point difference
assert abs(final_score - expected) < 0.01, \
f"Final score {final_score} doesn't match expected {expected} from components"
print("\n✓ Combined score verification test passed!")
finally:
await memory.delete_bank(bank_id)
@@ -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.5"
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"
-158
View File
@@ -1,158 +0,0 @@
#!/bin/bash
set -e
# Hindsight CLI installer
# Usage: curl -sSf https://your-domain.com/install.sh | sh
REPO_URL="https://github.com/vectorize-io/hindsight"
INSTALL_DIR="${HINDSIGHT_INSTALL_DIR:-$HOME/.local/bin}"
BINARY_NAME="hindsight"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
print_info() {
echo -e "${BLUE}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
}
print_banner() {
echo ""
echo -e "${BLUE}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ HINDSIGHT CLI INSTALLER ║${NC}"
echo -e "${BLUE}╚══════════════════════════════════════════════════╝${NC}"
echo ""
}
# Detect platform
detect_platform() {
local os=$(uname -s)
local arch=$(uname -m)
case "$os" in
Darwin)
if [[ "$arch" == "arm64" ]] || [[ "$arch" == "aarch64" ]]; then
echo "darwin-arm64"
elif [[ "$arch" == "x86_64" ]]; then
echo "darwin-amd64"
else
print_error "Unsupported macOS architecture: $arch"
exit 1
fi
;;
Linux)
if [[ "$arch" == "x86_64" ]]; then
echo "linux-amd64"
elif [[ "$arch" == "aarch64" ]] || [[ "$arch" == "arm64" ]]; then
echo "linux-arm64"
else
print_error "Unsupported Linux architecture: $arch"
exit 1
fi
;;
*)
print_error "Unsupported operating system: $os"
exit 1
;;
esac
}
# Download binary
download_binary() {
local platform=$1
local download_url="${REPO_URL}/releases/latest/download/hindsight-${platform}"
local tmp_file="/tmp/hindsight-$$"
print_info "Downloading Hindsight CLI for $platform..." >&2
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$download_url" -o "$tmp_file"
elif command -v wget > /dev/null 2>&1; then
wget -q "$download_url" -O "$tmp_file"
else
print_error "Neither curl nor wget found. Please install one of them." >&2
exit 1
fi
echo "$tmp_file"
}
# Install binary
install_binary() {
local tmp_file=$1
# Create install directory if it doesn't exist
mkdir -p "$INSTALL_DIR"
# Move binary to install directory
mv "$tmp_file" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
print_success "Installed to: $INSTALL_DIR/$BINARY_NAME"
}
# Check if directory is in PATH
check_path() {
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
print_warning "$INSTALL_DIR is not in your PATH"
echo ""
echo "Add it to your PATH by adding this line to your shell profile:"
echo ""
# Detect shell
if [[ -n "$BASH_VERSION" ]]; then
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
elif [[ -n "$ZSH_VERSION" ]]; then
echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
else
echo " export PATH=\"$INSTALL_DIR:\$PATH\""
fi
echo ""
fi
}
# Main installation flow
main() {
print_banner
# Detect platform
platform=$(detect_platform)
print_info "Detected platform: $platform"
# Download binary
tmp_file=$(download_binary "$platform")
# Install binary
install_binary "$tmp_file"
# Check PATH
check_path
print_success "Installation complete!"
echo ""
print_info "Try it out: $BINARY_NAME --help"
echo ""
print_info "Configure the API URL:"
echo " export HINDSIGHT_API_URL=http://localhost:8888"
echo ""
}
# Run installation
main
+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.5"
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(
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-client",
"version": "0.1.0",
"version": "0.1.5",
"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,
},
},
});
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./",
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hindsight-control-plane",
"version": "0.1.0",
"version": "0.1.5",
"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",
@@ -33,17 +36,17 @@
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next": "^16.0.10",
"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

+55 -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,38 @@
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;
}
/* Fix datetime-local calendar icon visibility in both light and dark modes */
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(0.5);
}
.dark input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(1);
}
+11 -5
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">
<body>
<BankProvider>
{children}
</BankProvider>
<html lang="en" suppressHydrationWarning>
<body className="bg-background text-foreground">
<ThemeProvider>
<BankProvider>
{children}
</BankProvider>
</ThemeProvider>
</body>
</html>
);
@@ -239,7 +239,7 @@ export function BankProfileView() {
<div className="flex gap-2">
{editMode ? (
<>
<Button onClick={handleCancel} variant="outline" disabled={saving}>
<Button onClick={handleCancel} variant="secondary" disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
@@ -258,7 +258,7 @@ export function BankProfileView() {
</>
) : (
<>
<Button onClick={loadData} variant="outline" size="sm">
<Button onClick={loadData} variant="secondary" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
@@ -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>
@@ -216,7 +266,7 @@ function BankSelectorInner() {
</div>
<DialogFooter>
<Button
variant="outline"
variant="secondary"
onClick={() => {
setCreateDialogOpen(false);
setNewBankId('');
@@ -245,7 +295,7 @@ function BankSelectorInner() {
</DialogHeader>
<div className="py-4 space-y-4">
<div>
<label className="font-bold block mb-1 text-sm">Content *</label>
<label className="font-bold block mb-1 text-sm text-foreground">Content *</label>
<Textarea
value={docContent}
onChange={(e) => setDocContent(e.target.value)}
@@ -256,7 +306,7 @@ function BankSelectorInner() {
</div>
<div>
<label className="font-bold block mb-1 text-sm">Context</label>
<label className="font-bold block mb-1 text-sm text-foreground">Context</label>
<Input
type="text"
value={docContext}
@@ -267,16 +317,17 @@ function BankSelectorInner() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-bold block mb-1 text-sm">Event Date</label>
<label className="font-bold block mb-1 text-sm text-foreground">Event Date</label>
<Input
type="datetime-local"
value={docEventDate}
onChange={(e) => setDocEventDate(e.target.value)}
className="text-foreground"
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm">Document ID</label>
<label className="font-bold block mb-1 text-sm text-foreground">Document ID</label>
<Input
type="text"
value={docDocumentId}
@@ -292,7 +343,7 @@ function BankSelectorInner() {
checked={docAsync}
onCheckedChange={(checked) => setDocAsync(checked as boolean)}
/>
<label htmlFor="async-doc" className="text-sm cursor-pointer">
<label htmlFor="async-doc" className="text-sm cursor-pointer text-foreground">
Process in background (async)
</label>
</div>
@@ -303,7 +354,7 @@ function BankSelectorInner() {
</div>
<DialogFooter>
<Button
variant="outline"
variant="secondary"
onClick={() => {
setDocDialogOpen(false);
setDocContent('');
@@ -333,17 +384,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,54 +479,48 @@ 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 text-foreground">{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 text-foreground">
{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 text-foreground">
{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();
copyToClipboard(row.id);
}}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
variant="secondary"
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">
@@ -716,7 +799,7 @@ function TimelineView({ data }: { data: any }) {
{/* Zoom controls */}
<div className="flex items-center border border-border rounded mr-2">
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={zoomOut}
disabled={granularity === 'year'}
@@ -725,11 +808,11 @@ function TimelineView({ data }: { data: any }) {
>
<ZoomOut className="h-3 w-3" />
</Button>
<span className="text-[10px] px-2 min-w-[50px] text-center border-x border-border">
<span className="text-[10px] px-2 min-w-[50px] text-center border-x border-border text-foreground">
{granularityLabels[granularity]}
</span>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={zoomIn}
disabled={granularity === 'day'}
@@ -743,7 +826,7 @@ function TimelineView({ data }: { data: any }) {
{/* Navigation controls */}
<div className="flex items-center border border-border rounded">
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => scrollToGroup(0)}
disabled={timelineGroups.length <= 1}
@@ -753,7 +836,7 @@ function TimelineView({ data }: { data: any }) {
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => scrollToGroup(currentIndex - 1)}
disabled={currentIndex === 0}
@@ -762,11 +845,11 @@ function TimelineView({ data }: { data: any }) {
>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-[10px] px-2 min-w-[60px] text-center border-x border-border">
<span className="text-[10px] px-2 min-w-[60px] text-center border-x border-border text-foreground">
{currentIndex + 1} / {timelineGroups.length}
</span>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => scrollToGroup(currentIndex + 1)}
disabled={currentIndex >= timelineGroups.length - 1}
@@ -776,7 +859,7 @@ function TimelineView({ data }: { data: any }) {
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => scrollToGroup(timelineGroups.length - 1)}
disabled={timelineGroups.length <= 1}
@@ -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>
))}
@@ -94,7 +94,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Document ID
</div>
<div className="text-sm font-mono break-all">{data.id}</div>
<div className="text-sm font-mono break-all text-foreground">{data.id}</div>
</div>
{data.created_at && (
<div className="grid grid-cols-2 gap-3">
@@ -102,7 +102,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Created
</div>
<div className="text-sm">
<div className="text-sm text-foreground">
{new Date(data.created_at).toLocaleString()}
</div>
</div>
@@ -110,7 +110,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Memory Units
</div>
<div className="text-sm">{data.memory_unit_count}</div>
<div className="text-sm text-foreground">{data.memory_unit_count}</div>
</div>
</div>
)}
@@ -119,7 +119,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Text Length
</div>
<div className="text-sm">
<div className="text-sm text-foreground">
{data.original_text.length.toLocaleString()} characters
</div>
</div>
@@ -132,7 +132,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
Original Text
</div>
<div className="p-4 bg-muted rounded-lg border border-border max-h-[300px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono">
<pre className="text-sm whitespace-pre-wrap font-mono text-foreground">
{data.original_text}
</pre>
</div>
@@ -146,7 +146,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Chunk ID
</div>
<div className="text-sm font-mono break-all">
<div className="text-sm font-mono break-all text-foreground">
{data.chunk_id}
</div>
</div>
@@ -155,7 +155,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Document ID
</div>
<div className="text-sm font-mono break-all">
<div className="text-sm font-mono break-all text-foreground">
{data.document_id}
</div>
</div>
@@ -163,7 +163,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Chunk Index
</div>
<div className="text-sm">{data.chunk_index}</div>
<div className="text-sm text-foreground">{data.chunk_index}</div>
</div>
</div>
{data.created_at && (
@@ -171,7 +171,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Created
</div>
<div className="text-sm">
<div className="text-sm text-foreground">
{new Date(data.created_at).toLocaleString()}
</div>
</div>
@@ -181,7 +181,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Text Length
</div>
<div className="text-sm">
<div className="text-sm text-foreground">
{data.chunk_text.length.toLocaleString()} characters
</div>
</div>
@@ -194,7 +194,7 @@ export function DocumentChunkModal({ type, id, onClose }: DocumentChunkModalProp
Chunk Text
</div>
<div className="p-4 bg-muted rounded-lg border border-border max-h-[300px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono">
<pre className="text-sm whitespace-pre-wrap font-mono text-foreground">
{data.chunk_text}
</pre>
</div>

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