prepare for release

This commit is contained in:
Nicolò Boschi 2025-12-03 11:52:25 +01:00
parent 943f6e7844
commit 09fbcc2020
45 changed files with 3079 additions and 1783 deletions

View file

@ -6,8 +6,17 @@ on:
- 'v*' - 'v*'
jobs: jobs:
build-python-package: build-python-packages:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -22,15 +31,46 @@ jobs:
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Build hindsight package - name: Build ${{ matrix.name }} package
working-directory: ./hindsight working-directory: ./${{ matrix.path }}
run: uv build run: uv build
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: python-hindsight-dist name: python-${{ matrix.name }}-dist
path: hindsight/dist/* path: ${{ matrix.path }}/dist/*
retention-days: 30
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'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Pack npm package
working-directory: ./hindsight-clients/typescript
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: typescript-client-dist
path: hindsight-clients/typescript/*.tgz
retention-days: 30 retention-days: 30
build-rust-cli: build-rust-cli:
@ -101,7 +141,14 @@ jobs:
packages: write packages: write
strategy: strategy:
matrix: matrix:
component: [api, control-plane] include:
# All images use the same Dockerfile with different --target
- target: api-only
image_name: hindsight-api
- target: cp-only
image_name: hindsight-control-plane
- target: standalone
image_name: hindsight
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -117,6 +164,9 @@ jobs:
docker-images: true docker-images: true
swap-storage: true swap-storage: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -135,32 +185,21 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/${{ github.repository_owner }}/hindsight-${{ matrix.component }} images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
tags: | tags: |
type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }} type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }} type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }}
type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }} type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }}
type=raw,value=latest type=raw,value=latest
- name: Build and push Docker image (api) - name: Build and push Docker image
if: matrix.component == 'api'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/api.Dockerfile file: docker/standalone/Dockerfile
push: true target: ${{ matrix.target }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image (control-plane)
if: matrix.component == 'control-plane'
uses: docker/build-push-action@v6
with:
context: .
file: docker/control-plane.Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
@ -192,9 +231,63 @@ jobs:
path: helm-packages/*.tgz path: helm-packages/*.tgz
retention-days: 30 retention-days: 30
publish-python-packages:
runs-on: ubuntu-latest
needs: [build-python-packages]
environment: pypi
strategy:
max-parallel: 1
matrix:
include:
# Order matters: client and api first, then hindsight-all (which depends on them)
- name: hindsight-client
- name: hindsight-api
- name: hindsight-all
steps:
- name: Download ${{ matrix.name }}
uses: actions/download-artifact@v4
with:
name: python-${{ matrix.name }}-dist
path: ./dist
- name: Publish ${{ matrix.name }} to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./dist
skip-existing: true
publish-npm-package:
runs-on: ubuntu-latest
needs: [build-typescript-client]
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-clients/typescript
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
create-github-release: create-github-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build-python-package, build-rust-cli, build-docker-images, package-helm-chart] needs: [build-python-packages, build-typescript-client, build-rust-cli, build-docker-images, package-helm-chart]
permissions: permissions:
contents: write contents: write
@ -205,11 +298,29 @@ jobs:
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Download Python package - name: Download hindsight-all
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: python-hindsight-dist name: python-hindsight-all-dist
path: ./artifacts/python-hindsight-dist path: ./artifacts/python-hindsight-all
- name: Download hindsight-api
uses: actions/download-artifact@v4
with:
name: python-hindsight-api-dist
path: ./artifacts/python-hindsight-api
- name: Download hindsight-client
uses: actions/download-artifact@v4
with:
name: python-hindsight-client-dist
path: ./artifacts/python-hindsight-client
- name: Download TypeScript Client
uses: actions/download-artifact@v4
with:
name: typescript-client-dist
path: ./artifacts/typescript-client
- name: Download Rust CLI (Linux) - name: Download Rust CLI (Linux)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@ -238,8 +349,12 @@ jobs:
- name: Prepare release assets - name: Prepare release assets
run: | run: |
mkdir -p release-assets mkdir -p release-assets
# Python package # Python packages
cp artifacts/python-hindsight-dist/* release-assets/ cp artifacts/python-hindsight-all/* release-assets/
cp artifacts/python-hindsight-api/* release-assets/
cp artifacts/python-hindsight-client/* release-assets/
# TypeScript Client
cp artifacts/typescript-client/*.tgz release-assets/
# Rust CLI binaries # Rust CLI binaries
cp artifacts/rust-cli-hindsight-linux-amd64/hindsight-linux-amd64 release-assets/ cp artifacts/rust-cli-hindsight-linux-amd64/hindsight-linux-amd64 release-assets/
cp artifacts/rust-cli-hindsight-darwin-amd64/hindsight-darwin-amd64 release-assets/ cp artifacts/rust-cli-hindsight-darwin-amd64/hindsight-darwin-amd64 release-assets/
@ -250,37 +365,61 @@ jobs:
- name: Generate release notes - name: Generate release notes
id: release_notes id: release_notes
run: | run: |
cat << EOF > release-notes.md cat << 'EOF' > release-notes.md
# Hindsight v${{ steps.get_version.outputs.VERSION }} # Hindsight v${{ steps.get_version.outputs.VERSION }}
## Quick Start
```bash
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 }}
```
## 📦 Release Artifacts ## 📦 Release Artifacts
### Python Package ### Docker Images
- \`hindsight-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl\` - `ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}` - **Standalone all-in-one** (recommended)
- \`hindsight-${{ steps.get_version.outputs.VERSION }}.tar.gz\` - `ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}` - API server only
- `ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}` - Web UI only
### Python Packages
- `hindsight-all` - All-in-one package (includes API + client)
- `hindsight-api` - API server
- `hindsight-client` - Client library
### TypeScript/JavaScript
- `@hindsight/client` - TypeScript SDK
### CLI Binaries ### CLI Binaries
- \`hindsight-linux-amd64\` - Linux x86_64 - `hindsight-linux-amd64` - Linux x86_64
- \`hindsight-darwin-amd64\` - macOS Intel - `hindsight-darwin-amd64` - macOS Intel
- \`hindsight-darwin-arm64\` - macOS Apple Silicon - `hindsight-darwin-arm64` - macOS Apple Silicon
### Helm Chart ### Helm Chart
- \`hindsight-${{ steps.get_version.outputs.VERSION }}.tgz\` - `hindsight-${{ steps.get_version.outputs.VERSION }}.tgz`
### Docker Images
Docker images are published to GitHub Container Registry:
- \`ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}\`
- \`ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}\`
## 🚀 Installation ## 🚀 Installation
### Python Package ### Python
\`\`\`bash ```bash
pip install hindsight==${{ steps.get_version.outputs.VERSION }} # All-in-one (recommended)
\`\`\` pip install hindsight-all==${{ steps.get_version.outputs.VERSION }}
# Or install components separately
pip install hindsight-api==${{ steps.get_version.outputs.VERSION }}
pip install hindsight-client==${{ steps.get_version.outputs.VERSION }}
```
### TypeScript/JavaScript
```bash
npm install @hindsight/client@${{ steps.get_version.outputs.VERSION }}
```
### CLI ### CLI
\`\`\`bash ```bash
# macOS (Apple Silicon) # macOS (Apple Silicon)
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-darwin-arm64 -o hindsight curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-darwin-arm64 -o hindsight
chmod +x hindsight chmod +x hindsight
@ -295,25 +434,12 @@ jobs:
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-linux-amd64 -o hindsight curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-linux-amd64 -o hindsight
chmod +x hindsight chmod +x hindsight
sudo mv hindsight /usr/local/bin/ sudo mv hindsight /usr/local/bin/
\`\`\` ```
### Helm Chart ### Helm (Kubernetes)
\`\`\`bash ```bash
helm install hindsight hindsight-${{ steps.get_version.outputs.VERSION }}.tgz helm install hindsight oci://ghcr.io/${{ github.repository_owner }}/charts/hindsight --version ${{ steps.get_version.outputs.VERSION }}
\`\`\` ```
### Docker
\`\`\`bash
# Pull API image
docker pull ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}
# Pull Control Plane image
docker pull ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}
# Or use latest
docker pull ghcr.io/${{ github.repository_owner }}/hindsight-api:latest
docker pull ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:latest
\`\`\`
EOF EOF
cat release-notes.md cat release-notes.md
@ -333,9 +459,10 @@ jobs:
echo "# Release v${{ steps.get_version.outputs.VERSION }} Published Successfully" >> $GITHUB_STEP_SUMMARY echo "# Release v${{ steps.get_version.outputs.VERSION }} Published Successfully" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "## 📦 Components" >> $GITHUB_STEP_SUMMARY echo "## 📦 Components" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Python package (hindsight)" >> $GITHUB_STEP_SUMMARY echo "- ✅ Python packages (hindsight-all, hindsight-api, hindsight-client)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ TypeScript Client (@hindsight/client)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Rust CLI (Linux amd64, macOS amd64, macOS arm64)" >> $GITHUB_STEP_SUMMARY echo "- ✅ Rust CLI (Linux amd64, macOS amd64, macOS arm64)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Docker images (API, Control Plane)" >> $GITHUB_STEP_SUMMARY echo "- ✅ Docker images (standalone, API, Control Plane)" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Helm chart" >> $GITHUB_STEP_SUMMARY echo "- ✅ Helm chart" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "🎉 Release is now available at: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY echo "🎉 Release is now available at: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY

67
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,67 @@
# Contributing to Hindsight
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
```
3. Set up your environment:
```bash
export OPENAI_API_KEY=your-key
```
## Development
### Running the API locally
```bash
./scripts/dev/start-api.sh
```
### Running the Control Plane locally
```bash
./scripts/dev/start-control-plane.sh
```
### Running the documentation locally
```bash
./scripts/dev/start-docs.sh
```
### Running tests
```bash
cd hindsight-api
uv run pytest tests/
```
### Code style
- Use Python type hints
- Follow existing code patterns
- Keep functions focused and well-named
## Pull Requests
1. Create a feature branch from `main`
2. Make your changes
3. Run tests to ensure nothing breaks
4. Submit a PR with a clear description of changes
## Reporting Issues
Open an issue on GitHub with:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, Python version)
## Questions?
Open a discussion on GitHub or reach out to the maintainers.

View file

@ -2,59 +2,96 @@
**Long-term memory for AI agents.** **Long-term memory for AI agents.**
AI assistants forget everything between sessions. Hindsight fixes that with a memory system that handles temporal reasoning, entity connections, and personality-aware responses.
## Why Hindsight? ## Why Hindsight?
- **Temporal queries** — "What did Alice do last spring?" requires more than vector search AI assistants forget everything between sessions. Every conversation starts from zero—no context about who you are, what you've discussed, or what the memory bank has learned. This isn't just inconvenient; it fundamentally limits what AI memory banks can do.
- **Entity connections** — Knowing "Alice works at Google" + "Google is in Mountain View" = "Alice works in Mountain View"
- **Agent opinions** — Agents form and recall beliefs with confidence scores
- **Personality** — Big Five traits influence how agents process and respond to information
## 60-seconds step **The problem is harder than it looks:**
- **Simple vector search isn't enough** — "What did Alice do last spring?" requires temporal reasoning, not just semantic similarity
- **Facts get disconnected** — Knowing "Alice works at Google" and "Google is in Mountain View" should let you answer "Where does Alice work?" even if you never stored that directly
- **Memory banks need opinions** — A coding assistant that remembers "the user prefers functional programming" should weigh that when making recommendations
- **Context matters** — The same information means different things to different memory banks with different personalities
Hindsight solves these problems with a memory system designed specifically for AI memory banks.
### 1. Install the Hindsight All package (client + API) ## Quick Start
### Option 1: Docker (recommended)
Get the full experience with the API and Control Plane UI:
```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 \
vectorize/hindsight
```
- **API**: http://localhost:8888
- **Control Plane UI**: http://localhost:9999
Then use the Python client:
```bash
pip install hindsight-client
```
```python
from hindsight import HindsightClient
client = HindsightClient(base_url="http://localhost:8888")
# Store memories
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
client.retain(bank_id="my-agent", content="Alice mentioned she loves hiking in the mountains")
# Query with temporal reasoning
results = client.recall(bank_id="my-agent", query="What does Alice do for work?")
# Get a synthesized perspective
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
print(response.text)
```
### Option 2: Embedded (no docker/server required)
For quick prototyping, run everything in-process:
```bash ```bash
pip install hindsight-all pip install hindsight-all
export OPENAI_API_KEY=your-key
``` ```
### 2. Import your OpenAI API key
```bash
export OPENAI_API_KEY=xx
```
### 3. Run embedded server and client
```python ```python
import os import os
from hindsight import HindsightServer, HindsightClient from hindsight import HindsightServer, HindsightClient
with HindsightServer(llm_provider="openai", llm_model="gpt-5.1-mini", llm_api_key=os.environ["OPENAI_API_KEY"]) as server: 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 = HindsightClient(base_url=server.url)
# Retain memories client.retain(bank_id="my-user", content="User prefers functional programming")
client.retain(bank_id="my-agent", content="Alice works at Google") response = client.reflect(bank_id="my-user", query="What coding style should I use?")
client.retain(bank_id="my-agent", content="Bob prefers Python over JavaScript") print(response.text)
# Recall memories
client.recall(bank_id="my-agent", query="What does Alice do?")
# Get memory perspective
client.reflect(bank_id="my-agent", query="Tell me about Alice")
``` ```
## Documentation ## Documentation
Full documentation: [hindsight-docs](./hindsight-docs) Full documentation: [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
- [Architecture](./hindsight-docs/docs/developer/architecture.md) — How ingestion, storage, and retrieval work - [Architecture](https://vectorize-io.github.io/hindsight/developer/architecture) — How ingestion, storage, and retrieval work
- [Python Client](./hindsight-docs/docs/sdks/python.md) — Full API reference - [Python Client](https://vectorize-io.github.io/hindsight/sdks/python) — Full API reference
- [API Reference](./hindsight-docs/docs/api-reference/index.md) — REST API endpoints - [API Reference](https://vectorize-io.github.io/hindsight/api-reference) — REST API endpoints
- [Personality](./hindsight-docs/docs/developer/personality.md) — Big Five traits and opinion formation - [Personality](https://vectorize-io.github.io/hindsight/developer/personality) — Big Five traits and opinion formation
## Contributing
We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
## License ## License

11
cookbook/README.md Normal file
View file

@ -0,0 +1,11 @@
# Hindsight Cookbook
For the cookbook with detailed examples, tutorials, and integrations, visit:
**[https://github.com/vectorize-io/hindsight-cookbook](https://github.com/vectorize-io/hindsight-cookbook)**
The cookbook repository includes:
- Integration examples with popular frameworks
- Real-world use cases and patterns
- Step-by-step tutorials
- Best practices and tips

View file

@ -1,59 +0,0 @@
# Distributed Hindsight Setup
Run API and Control Plane as separate containers.
## Start
```bash
cd services
docker-compose up
```
Access:
- **Control Plane**: http://localhost:3000
- **API**: http://localhost:8888
## What's Running
Two separate containers:
- `api` - Hindsight API with embedded pg0 database
- `control-plane` - Web UI
## Build Images
```bash
./build-all.sh
```
Creates:
- `hindsight/api:latest`
- `hindsight/control-plane:latest`
## Configuration
The API uses embedded pg0 by default. Database files are stored in the `api_data` volume.
To use an external PostgreSQL database, add to `docker-compose.yml`:
```yaml
services:
api:
environment:
HINDSIGHT_API_DATABASE_URL: postgresql://user:pass@host:5432/db
```
## Data Persistence
```bash
docker-compose down -v # Remove volumes
```
## Why Use This?
The distributed setup is useful when you want to:
- Scale API and UI independently
- Use an external database in production
- Deploy to Kubernetes/orchestration
- Run UI on different infrastructure
For simple deployments, use the main `docker-compose.yml` (standalone all-in-one).

View file

@ -1,33 +0,0 @@
# Dockerfile for Hindsight API (standalone)
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies and uv
RUN apt-get update && apt-get install -y \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Copy dependency files and README (required by pyproject.toml)
COPY hindsight-api/pyproject.toml ./
COPY hindsight-api/README.md ./
# Sync dependencies (creates lock file if needed)
RUN uv sync
# Copy source code
COPY hindsight-api/hindsight_api ./hindsight_api
# Expose API port
EXPOSE 8888
# Set environment variables
ENV HINDSIGHT_API_HOST=0.0.0.0
ENV HINDSIGHT_API_PORT=8888
ENV HINDSIGHT_API_LOG_LEVEL=info
ENV PATH="/app/.venv/bin:$PATH"
# Run the API server
CMD ["python", "-m", "hindsight_api.web.server"]

View file

@ -1,24 +0,0 @@
#!/bin/bash
set -e
echo "Building Hindsight service images..."
cd "$(dirname "$0")/../.."
echo ""
echo "Building hindsight-api..."
docker build -f docker/services/api.Dockerfile -t hindsight/api:latest .
echo ""
echo "Building hindsight-control-plane..."
docker build -f docker/services/control-plane.Dockerfile -t hindsight/control-plane:latest .
echo ""
echo "✅ All service images built successfully!"
echo ""
echo "Available images:"
echo " - hindsight/api:latest"
echo " - hindsight/control-plane:latest"
echo ""
echo "To start all services:"
echo " cd docker && docker-compose up"

View file

@ -1,65 +0,0 @@
# Dockerfile for Hindsight Control Plane (standalone)
FROM node:20-alpine AS sdk-builder
WORKDIR /app/sdk
# Build TypeScript SDK
COPY hindsight-clients/typescript/package*.json ./
RUN npm ci
COPY hindsight-clients/typescript/ ./
RUN npm run build
# Build Control Plane
FROM node:20-alpine AS builder
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
# Install Control Plane dependencies
COPY hindsight-control-plane/package*.json ./
RUN npm ci
# Copy Control Plane source
COPY hindsight-control-plane/ ./
# Link SDK for build
RUN cd /app/sdk && npm link && cd /app && npm link @hindsight/client
# Build the Next.js app
RUN npm run build
# Create public directory if it doesn't exist
RUN mkdir -p public
# Production image
FROM node:20-alpine
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
# Copy package files and install production dependencies only
COPY hindsight-control-plane/package*.json ./
RUN npm ci --omit=dev
# Link SDK for runtime
RUN cd /app/sdk && npm link && cd /app && npm link @hindsight/client
# Copy built app from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
# Expose control plane port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
# Run the Next.js server
CMD ["npm", "start"]

View file

@ -1,42 +0,0 @@
services:
api:
build:
context: ../..
dockerfile: docker/services/api.Dockerfile
ports:
- "8888:8888"
environment:
# Pass through all HINDSIGHT_* environment variables
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-}
HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-}
HINDSIGHT_API_LLM_BASE_URL: ${HINDSIGHT_API_LLM_BASE_URL:-}
HINDSIGHT_API_HOST: ${HINDSIGHT_API_HOST:-0.0.0.0}
HINDSIGHT_API_PORT: ${HINDSIGHT_API_PORT:-8888}
HINDSIGHT_API_LOG_LEVEL: ${HINDSIGHT_API_LOG_LEVEL:-info}
HINDSIGHT_API_DATABASE_URL: ${HINDSIGHT_API_DATABASE_URL:-}
volumes:
- api_data:/app/data
networks:
- hindsight
restart: unless-stopped
control-plane:
build:
context: ../..
dockerfile: docker/services/control-plane.Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: production
HINDSIGHT_CP_DATAPLANE_API_URL: http://api:8888
depends_on:
- api
networks:
- hindsight
restart: unless-stopped
volumes:
api_data:
networks:
hindsight:

View file

@ -1,6 +1,25 @@
# Standalone All-in-One Hindsight Image # Hindsight Docker Image
# API with embedded pg0 + Control Plane # Supports building API-only, Control Plane-only, or both
FROM python:3.11-slim AS api-base #
# Build args:
# INCLUDE_API=true/false - Include API (default: true)
# INCLUDE_CP=true/false - Include Control Plane (default: true)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
ARG INCLUDE_API=true
ARG INCLUDE_CP=true
# =============================================================================
# Stage: API Builder
# =============================================================================
FROM python:3.11-slim AS api-builder
ARG INCLUDE_API
RUN if [ "$INCLUDE_API" != "true" ]; then echo "Skipping API build" && exit 0; fi
WORKDIR /app WORKDIR /app
@ -21,12 +40,18 @@ WORKDIR /app/api
# Sync dependencies (will create lock file if needed) # Sync dependencies (will create lock file if needed)
RUN uv sync RUN uv sync
# Copy source code # Copy source code and alembic migrations
COPY hindsight-api/hindsight_api ./hindsight_api COPY hindsight-api/hindsight_api ./hindsight_api
COPY hindsight-api/alembic ./alembic
# Build TypeScript SDK # =============================================================================
# Stage: SDK Builder (needed for Control Plane)
# =============================================================================
FROM node:20-alpine AS sdk-builder FROM node:20-alpine AS sdk-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping SDK build" && exit 0; fi
WORKDIR /app/sdk WORKDIR /app/sdk
COPY hindsight-clients/typescript/package*.json ./ COPY hindsight-clients/typescript/package*.json ./
@ -35,9 +60,14 @@ RUN npm ci
COPY hindsight-clients/typescript/ ./ COPY hindsight-clients/typescript/ ./
RUN npm run build RUN npm run build
# Build Control Plane # =============================================================================
# Stage: Control Plane Builder
# =============================================================================
FROM node:20-alpine AS cp-builder FROM node:20-alpine AS cp-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi
WORKDIR /app WORKDIR /app
# Copy built SDK # Copy built SDK
@ -59,8 +89,120 @@ RUN npm run build
# Create public directory if it doesn't exist # Create public directory if it doesn't exist
RUN mkdir -p public RUN mkdir -p public
# Final standalone image # =============================================================================
FROM python:3.11-slim # Stage: Final Image - API Only
# =============================================================================
FROM python:3.11-slim AS api-only
WORKDIR /app
# Install pg0 dependencies
RUN apt-get update && apt-get install -y \
curl \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Create non-root user (PostgreSQL cannot run as root)
RUN useradd -m -s /bin/bash hindsight
# Copy API with virtual environment from builder
COPY --from=api-builder /app/api /app/api
# Copy startup script
COPY docker/standalone/start-all.sh /app/start-all.sh
RUN chmod +x /app/start-all.sh
# Create data directory for pg0 and set ownership
RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
# Switch to non-root user
USER hindsight
# Set PATH for hindsight user
ENV PATH="/home/hindsight/.hindsight/bin:/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
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
EXPOSE 8888
ENV HINDSIGHT_API_HOST=0.0.0.0
ENV HINDSIGHT_API_PORT=8888
ENV HINDSIGHT_API_LOG_LEVEL=info
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=false
CMD ["/app/start-all.sh"]
# =============================================================================
# Stage: Final Image - Control Plane Only
# =============================================================================
FROM node:20-alpine AS cp-only
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
WORKDIR /app
# Copy startup script
COPY docker/standalone/start-all.sh /app/start-all.sh
RUN chmod +x /app/start-all.sh
# Install curl for health checks
RUN apk add --no-cache curl bash
EXPOSE 9999
ENV NODE_ENV=production
ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV HINDSIGHT_ENABLE_API=false
ENV HINDSIGHT_ENABLE_CP=true
CMD ["/app/start-all.sh"]
# =============================================================================
# Stage: Final Image - Standalone (both API and Control Plane)
# =============================================================================
FROM python:3.11-slim AS standalone
WORKDIR /app WORKDIR /app
@ -70,6 +212,7 @@ RUN apt-get update && apt-get install -y \
libxml2 \ libxml2 \
libssl3 \ libssl3 \
libgssapi-krb5-2 \ libgssapi-krb5-2 \
libossp-uuid16 \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \ && apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
@ -80,22 +223,16 @@ RUN apt-get update && apt-get install -y \
RUN useradd -m -s /bin/bash hindsight RUN useradd -m -s /bin/bash hindsight
# Copy API with virtual environment from builder # Copy API with virtual environment from builder
COPY --from=api-base /app/api /app/api COPY --from=api-builder /app/api /app/api
# Copy built SDK # Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk COPY --from=sdk-builder /app/sdk /app/sdk
# Copy Control Plane # Copy Control Plane standalone build
WORKDIR /app/control-plane WORKDIR /app/control-plane
COPY --from=cp-builder /app/package*.json ./ COPY --from=cp-builder /app/.next/standalone ./
RUN npm ci --omit=dev COPY --from=cp-builder /app/.next/static ./.next/static
# Link SDK for runtime
RUN cd /app/sdk && npm link && cd /app/control-plane && npm link @hindsight/client
COPY --from=cp-builder /app/.next ./.next
COPY --from=cp-builder /app/public ./public COPY --from=cp-builder /app/public ./public
COPY --from=cp-builder /app/next.config.ts ./next.config.ts
WORKDIR /app WORKDIR /app
@ -109,24 +246,57 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
# Switch to non-root user # Switch to non-root user
USER hindsight USER hindsight
# Install pg0 # Set PATH for hindsight user
RUN curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
# Start pg0 once to verify it works and pre-download PostgreSQL libraries # Install pg0 binary
RUN pg0 --help && \ RUN mkdir -p /home/hindsight/.hindsight/bin && \
pg0 start --wait && \ ARCH=$(uname -m) && \
pg0 stop 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
# Expose ports # Pre-download PostgreSQL binaries
EXPOSE 8888 3000 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
EXPOSE 8888 9999
# Environment variables
ENV HINDSIGHT_API_HOST=0.0.0.0 ENV HINDSIGHT_API_HOST=0.0.0.0
ENV HINDSIGHT_API_PORT=8888 ENV HINDSIGHT_API_PORT=8888
ENV HINDSIGHT_API_LOG_LEVEL=info ENV HINDSIGHT_API_LOG_LEVEL=info
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888 ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV PATH="/home/hindsight/.local/bin:/app/api/.venv/bin:${PATH}" ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=true
# Run startup script
CMD ["/app/start-all.sh"] CMD ["/app/start-all.sh"]
# =============================================================================
# Default target selection based on build args
# =============================================================================
FROM standalone AS default-both
FROM api-only AS default-api
FROM cp-only AS default-cp
# This selects the final stage based on INCLUDE_API and INCLUDE_CP
# Use --target to override: docker build --target api-only .
FROM standalone

View file

@ -1,26 +1,24 @@
services: services:
hindsight: hindsight:
image: hindsight
build: build:
context: ../.. context: ../..
dockerfile: docker/standalone/Dockerfile dockerfile: docker/standalone/Dockerfile
platform: linux/amd64 env_file:
- ../../.env
ports: ports:
- "3000:3000" - "9999:9999"
- "8888:8888" - "8888:8888"
environment: environment:
# Pass through all HINDSIGHT_* environment variables from host # These override env_file values only when set in host shell
HINDSIGHT_API_LLM_API_KEY: ${HINDSIGHT_API_LLM_API_KEY:-} # Default values are applied only when not set in env_file or host
HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-}
HINDSIGHT_API_LLM_BASE_URL: ${HINDSIGHT_API_LLM_BASE_URL:-}
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-}
HINDSIGHT_API_HOST: ${HINDSIGHT_API_HOST:-0.0.0.0} HINDSIGHT_API_HOST: ${HINDSIGHT_API_HOST:-0.0.0.0}
HINDSIGHT_API_PORT: ${HINDSIGHT_API_PORT:-8888} HINDSIGHT_API_PORT: ${HINDSIGHT_API_PORT:-8888}
HINDSIGHT_API_LOG_LEVEL: ${HINDSIGHT_API_LOG_LEVEL:-info} HINDSIGHT_API_LOG_LEVEL: ${HINDSIGHT_API_LOG_LEVEL:-info}
# HINDSIGHT_API_DATABASE_URL can be set if you want to use an external database # HINDSIGHT_API_DATABASE_URL can be set if you want to use an external database
# If not set, embedded pg0 will be used automatically # If not set, embedded pg0 will be used automatically
# Add any other HINDSIGHT_* vars you need here
volumes: volumes:
- hindsight_data:/app/data - hindsight_data:/home/hindsight/.pg0
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View file

@ -4,36 +4,75 @@ set -e
echo "🚀 Starting Hindsight..." echo "🚀 Starting Hindsight..."
echo "" echo ""
# Start API (with embedded pg0) # Service flags (default to true if not set)
echo "⚡ Starting Hindsight API (with embedded database)..." ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
# Copy pre-cached PostgreSQL data if runtime directory is empty (first run with volume)
if [ "$ENABLE_API" = "true" ]; then
PG0_CACHE="/home/hindsight/.pg0-cache"
PG0_HOME="/home/hindsight/.pg0"
if [ -d "$PG0_CACHE" ] && [ "$(ls -A $PG0_CACHE 2>/dev/null)" ]; then
if [ ! "$(ls -A $PG0_HOME 2>/dev/null)" ]; then
echo "📦 Copying pre-cached PostgreSQL data..."
cp -r "$PG0_CACHE"/* "$PG0_HOME"/ 2>/dev/null || true
fi
fi
fi
# Track PIDs for wait
PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api cd /app/api
python -m hindsight_api.web.server & python -m hindsight_api.web.server 2>&1 | sed -u 's/^/[api] /' &
API_PID=$! API_PID=$!
PIDS+=($API_PID)
# Wait for API to be ready # Wait for API to be ready
echo "⏳ Waiting for API..." echo "⏳ Waiting for API..."
for i in {1..30}; do for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null || curl -sf http://localhost:8888/docs &>/dev/null; then if curl -sf http://localhost:8888/health &>/dev/null; then
echo "✅ API is ready" echo "✅ API is ready"
break break
fi fi
sleep 1 sleep 1
done done
else
echo "⏭️ API disabled (HINDSIGHT_ENABLE_API=false)"
fi
# Start Control Plane # Start Control Plane if enabled
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..." echo "🎛️ Starting Control Plane..."
cd /app/control-plane cd /app/control-plane
node .next/standalone/server.js & PORT=9999 node server.js 2>&1 | grep -v -E "^[[:space:]]*(▲|✓|-|$)" | sed -u 's/^/[control-plane] /' &
CP_PID=$! CP_PID=$!
PIDS+=($CP_PID)
else
echo "⏭️ Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
fi
# Print status
echo "" echo ""
echo "✅ Hindsight is running!" echo "✅ Hindsight is running!"
echo "" echo ""
echo "📍 Access:" echo "📍 Access:"
echo " Control Plane: http://localhost:3000" if [ "$ENABLE_CP" = "true" ]; then
echo " Control Plane: http://localhost:9999"
fi
if [ "$ENABLE_API" = "true" ]; then
echo " API: http://localhost:8888" echo " API: http://localhost:8888"
fi
echo "" echo ""
# Check if any services are running
if [ ${#PIDS[@]} -eq 0 ]; then
echo "❌ No services enabled! Set HINDSIGHT_ENABLE_API=true or HINDSIGHT_ENABLE_CP=true"
exit 1
fi
# Wait for any process to exit # Wait for any process to exit
wait -n wait -n

View file

@ -797,6 +797,24 @@ def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_mem
def _register_routes(app: FastAPI): def _register_routes(app: FastAPI):
"""Register all API routes on the given app instance.""" """Register all API routes on the given app instance."""
@app.get(
"/health",
summary="Health check endpoint",
description="Checks the health of the API and database connection",
tags=["Monitoring"]
)
async def health_endpoint():
"""
Health check endpoint that verifies database connectivity.
Returns 200 if healthy, 503 if unhealthy.
"""
from fastapi.responses import JSONResponse
health = await app.state.memory.health_check()
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
@app.get( @app.get(
"/metrics", "/metrics",
summary="Prometheus metrics endpoint", summary="Prometheus metrics endpoint",

View file

@ -2,11 +2,19 @@
import json import json
import logging import logging
import os
from fastmcp import FastMCP from fastmcp import FastMCP
from hindsight_api import MemoryEngine from hindsight_api import MemoryEngine
logging.basicConfig(level=logging.INFO) # Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {"critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING,
"info": logging.INFO, "debug": logging.DEBUG, "trace": logging.DEBUG}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -10,13 +10,23 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CrossEncoderReranker(ABC): class CrossEncoderModel(ABC):
""" """
Abstract base class for cross-encoder reranking. Abstract base class for cross-encoder reranking.
Cross-encoders take query-document pairs and return relevance scores. Cross-encoders take query-document pairs and return relevance scores.
""" """
@abstractmethod
def load(self) -> None:
"""
Load the cross-encoder model.
This should be called during initialization to load the model
and avoid cold start latency on first predict() call.
"""
pass
@abstractmethod @abstractmethod
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]: def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
""" """
@ -31,12 +41,11 @@ class CrossEncoderReranker(ABC):
pass pass
class SentenceTransformersCrossEncoder(CrossEncoderReranker): class SentenceTransformersCrossEncoder(CrossEncoderModel):
""" """
Cross-encoder implementation using SentenceTransformers. Cross-encoder implementation using SentenceTransformers.
Uses lazy import so sentence-transformers is not required if another Call load() during initialization to load the model and avoid cold starts.
reranking backend is used.
Default model is cross-encoder/ms-marco-MiniLM-L-6-v2: Default model is cross-encoder/ms-marco-MiniLM-L-6-v2:
- Fast inference (~80ms for 100 pairs on CPU) - Fast inference (~80ms for 100 pairs on CPU)
@ -46,13 +55,19 @@ class SentenceTransformersCrossEncoder(CrossEncoderReranker):
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"): def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
""" """
Initialize SentenceTransformers cross-encoder and load model. Initialize SentenceTransformers cross-encoder.
Args: Args:
model_name: Name of the CrossEncoder model to use. model_name: Name of the CrossEncoder model to use.
Default: cross-encoder/ms-marco-MiniLM-L-6-v2 Default: cross-encoder/ms-marco-MiniLM-L-6-v2
""" """
self.model_name = model_name self.model_name = model_name
self._model = None
def load(self) -> None:
"""Load the cross-encoder model."""
if self._model is not None:
return
try: try:
from sentence_transformers import CrossEncoder from sentence_transformers import CrossEncoder
@ -76,5 +91,7 @@ class SentenceTransformersCrossEncoder(CrossEncoderReranker):
Returns: Returns:
List of relevance scores (raw logits from the model) List of relevance scores (raw logits from the model)
""" """
scores = self._model.predict(pairs) if self._model is None:
self.load()
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, 'tolist') else list(scores) return scores.tolist() if hasattr(scores, 'tolist') else list(scores)

View file

@ -24,6 +24,16 @@ class Embeddings(ABC):
the database schema. the database schema.
""" """
@abstractmethod
def load(self) -> None:
"""
Load the embedding model.
This should be called during initialization to load the model
and avoid cold start latency on first encode() call.
"""
pass
@abstractmethod @abstractmethod
def encode(self, texts: List[str]) -> List[List[float]]: def encode(self, texts: List[str]) -> List[List[float]]:
""" """
@ -42,8 +52,7 @@ class SentenceTransformersEmbeddings(Embeddings):
""" """
Embeddings implementation using SentenceTransformers. Embeddings implementation using SentenceTransformers.
Uses lazy import so sentence-transformers is not required if another Call load() during initialization to load the model and avoid cold starts.
embedding backend is used.
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
embeddings matching the database schema. embeddings matching the database schema.
@ -60,11 +69,12 @@ class SentenceTransformersEmbeddings(Embeddings):
""" """
self.model_name = model_name self.model_name = model_name
self._model = None self._model = None
self._load_model()
def _load_model(self): def load(self) -> None:
"""Lazy load and validate the SentenceTransformer model.""" """Load the embedding model."""
if self._model is None: if self._model is not None:
return
try: try:
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
except ImportError: except ImportError:
@ -97,5 +107,7 @@ class SentenceTransformersEmbeddings(Embeddings):
Returns: Returns:
List of 384-dimensional embedding vectors List of 384-dimensional embedding vectors
""" """
if self._model is None:
self.load()
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False) embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings] return [emb.tolist() for emb in embeddings]

View file

@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict
import asyncpg import asyncpg
import asyncio import asyncio
from .embeddings import Embeddings, SentenceTransformersEmbeddings from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .cross_encoder import CrossEncoderReranker as CrossEncoderModel from .cross_encoder import CrossEncoderModel
import time import time
import numpy as np import numpy as np
import uuid import uuid
@ -362,15 +362,53 @@ class MemoryEngine:
logger.error(f"Failed to mark operation as failed {operation_id}: {e}") logger.error(f"Failed to mark operation as failed {operation_id}: {e}")
async def initialize(self): async def initialize(self):
"""Initialize the connection pool and background workers.""" """Initialize the connection pool, models, and background workers.
Loads models (embeddings, cross-encoder) in parallel with pg0 startup
for faster overall initialization.
"""
if self._initialized: if self._initialized:
return return
# Start pg0 embedded PostgreSQL if configured import concurrent.futures
# Run model loading in thread pool (CPU-bound) in parallel with pg0 startup
loop = asyncio.get_event_loop()
async def start_pg0():
"""Start pg0 if configured."""
if self._use_pg0: if self._use_pg0:
self._pg0 = EmbeddedPostgres() self._pg0 = EmbeddedPostgres()
self.db_url = await self._pg0.ensure_running() self.db_url = await self._pg0.ensure_running()
logger.info(f"Connecting to PostGre instance at {self.db_url}")
def load_embeddings():
"""Load embedding model (CPU-bound)."""
self.embeddings.load()
def load_cross_encoder():
"""Load cross-encoder model (CPU-bound)."""
self._cross_encoder_reranker.cross_encoder.load()
def load_query_analyzer():
"""Load query analyzer model (CPU-bound)."""
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)
# Wait for all to complete
await asyncio.gather(
pg0_task, embeddings_future, cross_encoder_future, query_analyzer_future
)
logger.info(f"Connecting to PostgreSQL at {self.db_url}")
# Create connection pool # Create connection pool
# For read-heavy workloads with many parallel think/search operations, # For read-heavy workloads with many parallel think/search operations,
@ -414,6 +452,24 @@ class MemoryEngine:
return await _retry_with_backoff(acquire) return await _retry_with_backoff(acquire)
async def health_check(self) -> dict:
"""
Perform a health check by querying the database.
Returns:
dict with status and optional error message
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
result = await conn.fetchval("SELECT 1")
if result == 1:
return {"status": "healthy", "database": "connected"}
else:
return {"status": "unhealthy", "database": "unexpected response"}
except Exception as e:
return {"status": "unhealthy", "database": "error", "error": str(e)}
async def close(self): async def close(self):
"""Close the connection pool and shutdown background workers.""" """Close the connection pool and shutdown background workers."""
logger.info("close() started") logger.info("close() started")
@ -503,8 +559,15 @@ class MemoryEngine:
if not texts: if not texts:
return [] return []
# Handle edge cases where event_date is at datetime boundaries
try:
time_lower = event_date - timedelta(hours=time_window_hours) time_lower = event_date - timedelta(hours=time_window_hours)
except OverflowError:
time_lower = datetime.min
try:
time_upper = event_date + timedelta(hours=time_window_hours) time_upper = event_date + timedelta(hours=time_window_hours)
except OverflowError:
time_upper = datetime.max
# Fetch ALL existing facts in time window ONCE (much faster than N queries) # Fetch ALL existing facts in time window ONCE (much faster than N queries)
import time as time_mod import time as time_mod

View file

@ -46,6 +46,16 @@ class QueryAnalyzer(ABC):
information like temporal constraints, entities, etc. information like temporal constraints, entities, etc.
""" """
@abstractmethod
def load(self) -> None:
"""
Load the query analyzer model.
This should be called during initialization to load the model
and avoid cold start latency on first analyze() call.
"""
pass
@abstractmethod @abstractmethod
def analyze( def analyze(
self, query: str, reference_date: Optional[datetime] = None self, query: str, reference_date: Optional[datetime] = None
@ -94,9 +104,11 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
self._model = None self._model = None
self._tokenizer = None self._tokenizer = None
def _load_model(self): def load(self) -> None:
"""Lazy load the T5 model for temporal extraction.""" """Load the T5 model for temporal extraction."""
if self._model is None: if self._model is not None:
return
try: try:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
except ImportError: except ImportError:
@ -105,10 +117,16 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
"Install it with: pip install transformers" "Install it with: pip install transformers"
) )
logger.info(f"Loading query analyzer model: {self.model_name}...")
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name) self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
self._model.to(self.device) self._model.to(self.device)
self._model.eval() self._model.eval()
logger.info("Query analyzer model loaded")
def _load_model(self):
"""Lazy load the T5 model for temporal extraction (calls load())."""
self.load()
def analyze( def analyze(
self, query: str, reference_date: Optional[datetime] = None self, query: str, reference_date: Optional[datetime] = None

View file

@ -5,11 +5,107 @@ Link creation utilities for temporal, semantic, and entity links.
import time import time
import logging import logging
from typing import List from typing import List
from datetime import timedelta from datetime import timedelta, datetime, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_datetime(dt):
"""Normalize datetime to be timezone-aware (UTC) for consistent comparison."""
if dt is None:
return None
if dt.tzinfo is None:
# Naive datetime - assume UTC
return dt.replace(tzinfo=timezone.utc)
return dt
def compute_temporal_links(
new_units: dict,
candidates: list,
time_window_hours: int = 24,
) -> list:
"""
Compute temporal links between new units and candidate neighbors.
This is a pure function that takes query results and returns link tuples,
making it easy to test without database access.
Args:
new_units: Dict mapping unit_id (str) to event_date (datetime)
candidates: List of dicts with 'id' and 'event_date' keys (candidate neighbors)
time_window_hours: Time window in hours for temporal links
Returns:
List of tuples: (from_unit_id, to_unit_id, 'temporal', weight, None)
"""
if not new_units:
return []
links = []
for unit_id, unit_event_date in new_units.items():
# Normalize unit_event_date for consistent comparison
unit_event_date_norm = _normalize_datetime(unit_event_date)
# Calculate time window bounds with overflow protection
try:
time_lower = unit_event_date_norm - timedelta(hours=time_window_hours)
except OverflowError:
time_lower = datetime.min.replace(tzinfo=timezone.utc)
try:
time_upper = unit_event_date_norm + timedelta(hours=time_window_hours)
except OverflowError:
time_upper = datetime.max.replace(tzinfo=timezone.utc)
# Filter candidates within this unit's time window
matching_neighbors = [
(row['id'], row['event_date'])
for row in candidates
if time_lower <= _normalize_datetime(row['event_date']) <= time_upper
][:10] # Limit to top 10
for recent_id, recent_event_date in matching_neighbors:
# Calculate temporal proximity weight
time_diff_hours = abs((unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, str(recent_id), 'temporal', weight, None))
return links
def compute_temporal_query_bounds(
new_units: dict,
time_window_hours: int = 24,
) -> tuple:
"""
Compute the min/max date bounds for querying temporal neighbors.
Args:
new_units: Dict mapping unit_id (str) to event_date (datetime)
time_window_hours: Time window in hours
Returns:
Tuple of (min_date, max_date) with overflow protection
"""
if not new_units:
return None, None
# Normalize all dates to be timezone-aware to avoid comparison issues
all_dates = [_normalize_datetime(d) for d in new_units.values()]
try:
min_date = min(all_dates) - timedelta(hours=time_window_hours)
except OverflowError:
min_date = datetime.min.replace(tzinfo=timezone.utc)
try:
max_date = max(all_dates) + timedelta(hours=time_window_hours)
except OverflowError:
max_date = datetime.max.replace(tzinfo=timezone.utc)
return min_date, max_date
def _log(log_buffer, message, level='info'): def _log(log_buffer, message, level='info'):
"""Helper to log to buffer if available, otherwise use logger.""" """Helper to log to buffer if available, otherwise use logger."""
if log_buffer is not None: if log_buffer is not None:
@ -267,10 +363,8 @@ async def create_temporal_links_batch_per_fact(
_log(log_buffer, f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s") _log(log_buffer, f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s")
# Fetch ALL potential temporal neighbors in ONE query (much faster!) # Fetch ALL potential temporal neighbors in ONE query (much faster!)
# Get time range across all units # Get time range across all units with overflow protection
all_dates = list(new_units.values()) min_date, max_date = compute_temporal_query_bounds(new_units, time_window_hours)
min_date = min(all_dates) - timedelta(hours=time_window_hours)
max_date = max(all_dates) + timedelta(hours=time_window_hours)
fetch_neighbors_start = time_mod.time() fetch_neighbors_start = time_mod.time()
all_candidates = await conn.fetch( all_candidates = await conn.fetch(
@ -291,24 +385,7 @@ async def create_temporal_links_batch_per_fact(
# Filter and create links in memory (much faster than N queries) # Filter and create links in memory (much faster than N queries)
link_gen_start = time_mod.time() link_gen_start = time_mod.time()
links = [] links = compute_temporal_links(new_units, all_candidates, time_window_hours)
for unit_id, unit_event_date in new_units.items():
# Filter candidates within this unit's time window
time_lower = unit_event_date - timedelta(hours=time_window_hours)
time_upper = unit_event_date + timedelta(hours=time_window_hours)
matching_neighbors = [
(row['id'], row['event_date'])
for row in all_candidates
if time_lower <= row['event_date'] <= time_upper
][:10] # Limit to top 10
for recent_id, recent_event_date in matching_neighbors:
# Calculate temporal proximity weight
time_diff_hours = abs((unit_event_date - recent_event_date).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, str(recent_id), 'temporal', weight, None))
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s") _log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links: if links:

View file

@ -23,9 +23,11 @@ class CrossEncoderReranker:
Args: Args:
cross_encoder: CrossEncoderReranker instance. If None, uses default cross_encoder: CrossEncoderReranker instance. If None, uses default
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2 SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
(loaded lazily for faster startup)
""" """
if cross_encoder is None: if cross_encoder is None:
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
# Model is loaded lazily - call ensure_loaded() during initialize()
cross_encoder = SentenceTransformersCrossEncoder() cross_encoder = SentenceTransformersCrossEncoder()
self.cross_encoder = cross_encoder self.cross_encoder = cross_encoder

View file

@ -3,10 +3,10 @@ import json
import logging import logging
import os import os
import platform import platform
import re
import shutil import shutil
import stat import stat
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -14,8 +14,7 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DATA_DIR = Path(os.environ.get("HINDSIGHT_API_PG0_DATA_DIR", Path.home() / ".hindsight" / "pg_data")) # pg0 configuration
DEFAULT_INSTALL_DIR = Path.home() / ".hindsight" / "bin"
BINARY_NAME = "pg0" BINARY_NAME = "pg0"
DEFAULT_PORT = 5555 DEFAULT_PORT = 5555
DEFAULT_USERNAME = "hindsight" DEFAULT_USERNAME = "hindsight"
@ -65,9 +64,7 @@ def get_download_url(
version: str = "latest", version: str = "latest",
repo: str = "vectorize-io/pg0", repo: str = "vectorize-io/pg0",
) -> str: ) -> str:
""" """Get the download URL for pg0 binary."""
"""
# Check for direct URL override
binary_name = get_platform_binary_name() binary_name = get_platform_binary_name()
if version == "latest": if version == "latest":
@ -76,17 +73,32 @@ def get_download_url(
return f"https://github.com/{repo}/releases/download/{version}/{binary_name}" 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: class EmbeddedPostgres:
""" """
Manages an embedded PostgreSQL server instance. Manages an embedded PostgreSQL server instance using pg0.
This class handles: This class handles:
- Downloading and installing the embedded-postgres CLI - Finding or downloading the pg0 CLI
- Starting/stopping the PostgreSQL server - Starting/stopping the PostgreSQL server
- Getting the connection URI - Getting the connection URI
Example: Example:
pg = EmbeddedPostgres(data_dir="~/.myapp/data") pg = EmbeddedPostgres()
await pg.ensure_installed() await pg.ensure_installed()
await pg.start() await pg.start()
uri = await pg.get_uri() uri = await pg.get_uri()
@ -96,8 +108,6 @@ class EmbeddedPostgres:
def __init__( def __init__(
self, self,
data_dir: Optional[Path] = None,
install_dir: Optional[Path] = None,
version: str = "latest", version: str = "latest",
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
username: str = DEFAULT_USERNAME, username: str = DEFAULT_USERNAME,
@ -109,17 +119,13 @@ class EmbeddedPostgres:
Initialize the embedded PostgreSQL manager. Initialize the embedded PostgreSQL manager.
Args: Args:
data_dir: Directory to store PostgreSQL data. Defaults to ~/.hindsight/pg_data version: Version of pg0 to download if not found. Defaults to "latest"
install_dir: Directory to install the CLI binary. Defaults to ~/.hindsight/bin
version: Version of embedded-postgres to use. Defaults to "latest"
port: Port to listen on. Defaults to 5555 port: Port to listen on. Defaults to 5555
username: Username for the database. Defaults to "hindsight" username: Username for the database. Defaults to "hindsight"
password: Password for the database. Defaults to "hindsight" password: Password for the database. Defaults to "hindsight"
database: Database name to create. Defaults to "hindsight" database: Database name to create. Defaults to "hindsight"
name: Instance name for pg0. Defaults to "hindsight" name: Instance name for pg0. Defaults to "hindsight"
""" """
self.data_dir = Path(data_dir or DEFAULT_DATA_DIR).expanduser()
self.install_dir = Path(install_dir or DEFAULT_INSTALL_DIR).expanduser()
self.version = version self.version = version
self.port = port self.port = port
self.username = username self.username = username
@ -127,35 +133,42 @@ class EmbeddedPostgres:
self.database = database self.database = database
self.name = name self.name = name
# Binary path # Will be set when binary is found/installed
binary_name = "pg0.exe" if platform.system() == "Windows" else "pg0" self._binary_path: Optional[Path] = _find_pg0_binary()
self.binary_path = self.install_dir / binary_name
self._process: Optional[subprocess.Popen] = None @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: def is_installed(self) -> bool:
"""Check if the embedded-postgres CLI is installed.""" """Check if pg0 is available (in PATH or installed)."""
return self.binary_path.exists() and os.access(self.binary_path, os.X_OK) self._binary_path = _find_pg0_binary()
return self._binary_path is not None
async def ensure_installed(self) -> None: async def ensure_installed(self) -> None:
""" """
Ensure the embedded-postgres CLI is installed. Ensure pg0 is available.
Downloads and installs the binary if not already present. First checks PATH, then default location, then downloads if needed.
""" """
if self.is_installed(): if self.is_installed():
logger.info(f"pg0 already installed at {self.binary_path}") logger.debug(f"pg0 found at {self._binary_path}")
return return
logger.info("Installing pg0 CLI...") logger.info("pg0 not found, downloading...")
# Log platform information # Log platform information
binary_name = get_platform_binary_name() binary_name = get_platform_binary_name()
logger.info(f"Detected platform: system={platform.system()}, machine={platform.machine()}") logger.info(f"Detected platform: system={platform.system()}, machine={platform.machine()}")
logger.info(f"Will download binary: {binary_name}")
# Create install directory # Install to default location
self.install_dir.mkdir(parents=True, exist_ok=True) install_dir = Path.home() / ".hindsight" / "bin"
install_dir.mkdir(parents=True, exist_ok=True)
install_path = install_dir / "pg0"
# Download the binary # Download the binary
download_url = get_download_url(self.version) download_url = get_download_url(self.version)
@ -167,60 +180,74 @@ class EmbeddedPostgres:
response.raise_for_status() response.raise_for_status()
# Write binary to disk # Write binary to disk
with open(self.binary_path, "wb") as f: with open(install_path, "wb") as f:
f.write(response.content) f.write(response.content)
# Make executable on Unix # Make executable on Unix
if platform.system() != "Windows": if platform.system() != "Windows":
st = os.stat(self.binary_path) st = os.stat(install_path)
os.chmod(self.binary_path, st.st_mode | stat.S_IEXEC) os.chmod(install_path, st.st_mode | stat.S_IEXEC)
logger.info(f"Installed pg0 to {self.binary_path}") self._binary_path = install_path
logger.info(f"Installed pg0 to {install_path}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise RuntimeError(f"Failed to download pg0: {e}") from e raise RuntimeError(f"Failed to download pg0: {e}") from e
def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess: def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess:
"""Run an embedded-postgres command synchronously.""" """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] cmd = [str(self.binary_path), *args]
return subprocess.run( def run_sync():
try:
result = subprocess.run(
cmd, cmd,
capture_output=capture_output, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, text=True,
timeout=timeout,
) )
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return 1, "", "Command timed out"
async def _run_command_async(self, *args: str) -> tuple[int, str, str]: loop = asyncio.get_event_loop()
"""Run an embedded-postgres command asynchronously.""" return await loop.run_in_executor(None, run_sync)
cmd = [str(self.binary_path), *args]
process = await asyncio.create_subprocess_exec( def _extract_uri_from_output(self, output: str) -> Optional[str]:
*cmd, """Extract the PostgreSQL URI from pg0 start output."""
stdout=asyncio.subprocess.PIPE, match = re.search(r"Connection URI:\s*(postgresql://[^\s]+)", output)
stderr=asyncio.subprocess.PIPE, if match:
) return match.group(1)
return None
stdout, stderr = await process.communicate() async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
return process.returncode, stdout.decode(), stderr.decode()
async def start(self) -> str:
""" """
Start the PostgreSQL server. 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: Returns:
The connection URI for the started server. The connection URI for the started server.
Raises: Raises:
RuntimeError: If the server fails to start. RuntimeError: If the server fails to start after all retries.
""" """
if not self.is_installed(): if not self.is_installed():
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.") raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
# Create data directory logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
self.data_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, data: {self.data_dir}, install: {self.install_dir}, port: {self.port})...")
last_error = None
for attempt in range(1, max_retries + 1):
returncode, stdout, stderr = await self._run_command_async( returncode, stdout, stderr = await self._run_command_async(
"start", "start",
"--name", self.name, "--name", self.name,
@ -228,24 +255,40 @@ class EmbeddedPostgres:
"--username", self.username, "--username", self.username,
"--password", self.password, "--password", self.password,
"--database", self.database, "--database", self.database,
"--data-dir", self.data_dir.as_posix() timeout=300,
) )
if returncode != 0: # Try to extract URI from output
raise RuntimeError(f"Failed to start PostgreSQL: {stderr}") uri = self._extract_uri_from_output(stdout)
if uri:
logger.info(f"PostgreSQL started on port {self.port}")
return uri
logger.info("Embedded PostgreSQL started") # Check if pg0 info can find the running instance
try:
uri = await self.get_uri()
logger.info(f"PostgreSQL started on port {self.port}")
return uri
except RuntimeError:
pass
# Get and return the URI # Start failed, log and retry
return await self.get_uri() 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
async def stop(self) -> None: async def stop(self) -> None:
""" """Stop the PostgreSQL server."""
Stop the PostgreSQL server.
Raises:
RuntimeError: If the server fails to stop.
"""
if not self.is_installed(): if not self.is_installed():
return return
@ -254,7 +297,6 @@ class EmbeddedPostgres:
returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name) returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name)
if returncode != 0: if returncode != 0:
# Don't raise if server wasn't running
if "not running" in stderr.lower(): if "not running" in stderr.lower():
return return
raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}") raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}")
@ -262,20 +304,13 @@ class EmbeddedPostgres:
logger.info("Embedded PostgreSQL stopped") logger.info("Embedded PostgreSQL stopped")
async def _get_info(self) -> dict: async def _get_info(self) -> dict:
""" """Get info from pg0 using the `info -o json` command."""
Get info from pg0 using the `info -o json` command.
Returns:
Dictionary with 'running' (bool) and 'uri' (str) keys.
Raises:
RuntimeError: If unable to get info.
"""
if not self.is_installed(): if not self.is_installed():
raise RuntimeError("pg0 is not installed.") raise RuntimeError("pg0 is not installed.")
returncode, stdout, stderr = await self._run_command_async( returncode, stdout, stderr = await self._run_command_async(
"info", "--name", self.name, "-o", "json") "info", "--name", self.name, "-o", "json"
)
if returncode != 0: if returncode != 0:
raise RuntimeError(f"Failed to get PostgreSQL info: {stderr}") raise RuntimeError(f"Failed to get PostgreSQL info: {stderr}")
@ -286,15 +321,7 @@ class EmbeddedPostgres:
raise RuntimeError(f"Failed to parse pg0 info output: {e}") raise RuntimeError(f"Failed to parse pg0 info output: {e}")
async def get_uri(self) -> str: async def get_uri(self) -> str:
""" """Get the connection URI for the PostgreSQL server."""
Get the connection URI for the PostgreSQL server.
Returns:
PostgreSQL connection URI (e.g., postgresql://user:pass@localhost:5432/db)
Raises:
RuntimeError: If unable to get the URI or server is not running.
"""
info = await self._get_info() info = await self._get_info()
uri = info.get("uri") uri = info.get("uri")
if not uri: if not uri:
@ -302,12 +329,7 @@ class EmbeddedPostgres:
return uri return uri
async def status(self) -> dict: async def status(self) -> dict:
""" """Get the status of the PostgreSQL server."""
Get the status of the PostgreSQL server.
Returns:
Dictionary with status information including 'running' boolean and 'uri'.
"""
if not self.is_installed(): if not self.is_installed():
return {"installed": False, "running": False} return {"installed": False, "running": False}
@ -317,16 +339,9 @@ class EmbeddedPostgres:
"installed": True, "installed": True,
"running": info.get("running", False), "running": info.get("running", False),
"uri": info.get("uri"), "uri": info.get("uri"),
"data_dir": str(self.data_dir),
"binary_path": str(self.binary_path),
} }
except RuntimeError: except RuntimeError:
return { return {"installed": True, "running": False}
"installed": True,
"running": False,
"data_dir": str(self.data_dir),
"binary_path": str(self.binary_path),
}
async def is_running(self) -> bool: async def is_running(self) -> bool:
"""Check if the PostgreSQL server is currently running.""" """Check if the PostgreSQL server is currently running."""
@ -355,59 +370,42 @@ class EmbeddedPostgres:
return await self.start() return await self.start()
def uninstall(self) -> None: def uninstall(self) -> None:
"""Remove the embedded-postgres binary.""" """Remove the pg0 binary (only if we installed it)."""
if self.binary_path.exists(): default_path = Path.home() / ".hindsight" / "bin" / "pg0"
self.binary_path.unlink() if default_path.exists():
logger.info(f"Removed {self.binary_path}") default_path.unlink()
logger.info(f"Removed {default_path}")
def clear_data(self) -> None: def clear_data(self) -> None:
"""Remove all PostgreSQL data (destructive!).""" """Remove all PostgreSQL data (destructive!)."""
if self.data_dir.exists(): result = self._run_command("drop", "--name", self.name, "--force")
shutil.rmtree(self.data_dir) if result.returncode == 0:
logger.info(f"Removed data directory {self.data_dir}") logger.info(f"Dropped pg0 instance {self.name}")
else:
logger.warning(f"Failed to drop pg0 instance {self.name}: {result.stderr}")
# Convenience functions for simple usage # Convenience functions
_default_instance: Optional[EmbeddedPostgres] = None _default_instance: Optional[EmbeddedPostgres] = None
def get_embedded_postgres( def get_embedded_postgres() -> EmbeddedPostgres:
data_dir: Optional[Path] = None, """Get or create the default EmbeddedPostgres instance."""
install_dir: Optional[Path] = None,
) -> EmbeddedPostgres:
"""
Get or create the default EmbeddedPostgres instance.
Args:
data_dir: Override default data directory
install_dir: Override default install directory
Returns:
EmbeddedPostgres instance
"""
global _default_instance global _default_instance
if _default_instance is None or data_dir or install_dir: if _default_instance is None:
_default_instance = EmbeddedPostgres( _default_instance = EmbeddedPostgres()
data_dir=data_dir,
install_dir=install_dir,
)
return _default_instance return _default_instance
async def start_embedded_postgres( async def start_embedded_postgres() -> str:
data_dir: Optional[Path] = None,
) -> str:
""" """
Quick start function for embedded PostgreSQL. Quick start function for embedded PostgreSQL.
Downloads, installs, and starts PostgreSQL in one call. Downloads, installs, and starts PostgreSQL in one call.
Args:
data_dir: Directory to store PostgreSQL data
Returns: Returns:
Connection URI string Connection URI string
@ -415,7 +413,7 @@ async def start_embedded_postgres(
db_url = await start_embedded_postgres() db_url = await start_embedded_postgres()
conn = await asyncpg.connect(db_url) conn = await asyncpg.connect(db_url)
""" """
pg = get_embedded_postgres(data_dir=data_dir) pg = get_embedded_postgres()
return await pg.ensure_running() return await pg.ensure_running()

View file

@ -79,7 +79,10 @@ app = create_app(
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
logging.basicConfig(level=logging.INFO) # 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 # Parse CLI arguments
parser = argparse.ArgumentParser(description="Memory Graph API Server") parser = argparse.ArgumentParser(description="Memory Graph API Server")
@ -87,8 +90,8 @@ if __name__ == "__main__":
parser.add_argument("--port", type=int, default=8888, help="Port to bind to (default: 8888)") 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("--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("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
parser.add_argument("--log-level", default="info", choices=["critical", "error", "warning", "info", "debug", "trace"], parser.add_argument("--log-level", default=env_log_level, choices=["critical", "error", "warning", "info", "debug", "trace"],
help="Log level (default: info)") 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("--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("--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("--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers")
@ -99,6 +102,21 @@ if __name__ == "__main__":
args = parser.parse_args() 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" app_ref = "hindsight_api.web.server:app"
# Prepare uvicorn config # Prepare uvicorn config

View file

@ -0,0 +1,256 @@
"""Tests for link_utils datetime handling and temporal link computation."""
import pytest
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.retain.link_utils import (
_normalize_datetime,
compute_temporal_links,
compute_temporal_query_bounds,
)
class TestNormalizeDatetime:
"""Tests for the _normalize_datetime helper function."""
def test_none_returns_none(self):
"""Test that None input returns None."""
assert _normalize_datetime(None) is None
def test_naive_datetime_becomes_utc(self):
"""Test that naive datetimes are converted to UTC."""
naive_dt = datetime(2024, 6, 15, 10, 30, 0)
result = _normalize_datetime(naive_dt)
assert result.tzinfo is not None
assert result.tzinfo == timezone.utc
assert result.year == 2024
assert result.month == 6
assert result.day == 15
assert result.hour == 10
assert result.minute == 30
def test_aware_datetime_unchanged(self):
"""Test that timezone-aware datetimes are returned unchanged."""
aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
result = _normalize_datetime(aware_dt)
assert result == aware_dt
assert result.tzinfo == timezone.utc
def test_mixed_datetimes_can_be_compared(self):
"""Test that normalized naive and aware datetimes can be compared."""
naive_dt = datetime(2024, 6, 15, 10, 30, 0)
aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
normalized_naive = _normalize_datetime(naive_dt)
normalized_aware = _normalize_datetime(aware_dt)
# Should be able to compare without TypeError
assert normalized_naive == normalized_aware
class TestComputeTemporalQueryBounds:
"""Tests for compute_temporal_query_bounds function."""
def test_empty_units_returns_none(self):
"""Test that empty input returns (None, None)."""
min_date, max_date = compute_temporal_query_bounds({})
assert min_date is None
assert max_date is None
def test_single_unit_normal_date(self):
"""Test bounds for a single unit with normal date."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
assert min_date == datetime(2024, 6, 14, 12, 0, 0, tzinfo=timezone.utc)
assert max_date == datetime(2024, 6, 16, 12, 0, 0, tzinfo=timezone.utc)
def test_multiple_units(self):
"""Test bounds span across multiple units."""
units = {
"unit-1": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc),
"unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
"unit-3": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc),
}
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
# min should be Jun 10 - 24h = Jun 9
assert min_date == datetime(2024, 6, 9, 12, 0, 0, tzinfo=timezone.utc)
# max should be Jun 20 + 24h = Jun 21
assert max_date == datetime(2024, 6, 21, 12, 0, 0, tzinfo=timezone.utc)
def test_mixed_naive_and_aware_datetimes(self):
"""Test that mixed naive/aware datetimes work correctly."""
units = {
"unit-1": datetime(2024, 6, 10, 12, 0, 0), # naive
"unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), # aware
}
# Should not raise TypeError
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24)
assert min_date is not None
assert max_date is not None
assert min_date.tzinfo is not None
assert max_date.tzinfo is not None
def test_overflow_near_datetime_min(self):
"""Test overflow protection near datetime.min."""
units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)}
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48)
# Should handle overflow gracefully
assert min_date == datetime.min.replace(tzinfo=timezone.utc)
assert max_date is not None
def test_overflow_near_datetime_max(self):
"""Test overflow protection near datetime.max."""
units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)}
min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48)
# Should handle overflow gracefully
assert min_date is not None
assert max_date == datetime.max.replace(tzinfo=timezone.utc)
class TestComputeTemporalLinks:
"""Tests for compute_temporal_links function."""
def test_empty_units_returns_empty(self):
"""Test that empty input returns empty list."""
links = compute_temporal_links({}, [])
assert links == []
def test_no_candidates_returns_empty(self):
"""Test that no candidates means no links."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
links = compute_temporal_links(units, [])
assert links == []
def test_candidate_within_window_creates_link(self):
"""Test that candidates within time window create links."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
candidates = [
{"id": "candidate-1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)},
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 1
assert links[0][0] == "unit-1"
assert links[0][1] == "candidate-1"
assert links[0][2] == "temporal"
assert links[0][4] is None
# Weight should be high since they're close (2 hours apart)
assert links[0][3] > 0.9
def test_candidate_outside_window_no_link(self):
"""Test that candidates outside time window don't create links."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
candidates = [
{"id": "candidate-1", "event_date": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc)},
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 0
def test_weight_decreases_with_distance(self):
"""Test that weight decreases as time difference increases."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
candidates = [
{"id": "close", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)}, # 1 hour
{"id": "far", "event_date": datetime(2024, 6, 14, 18, 0, 0, tzinfo=timezone.utc)}, # 18 hours
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 2
close_link = next(l for l in links if l[1] == "close")
far_link = next(l for l in links if l[1] == "far")
assert close_link[3] > far_link[3]
def test_max_10_links_per_unit(self):
"""Test that at most 10 links are created per unit."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
# Create 15 candidates all within window
candidates = [
{"id": f"candidate-{i}", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)}
for i in range(15)
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 10
def test_multiple_units_multiple_candidates(self):
"""Test with multiple units and candidates."""
units = {
"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc),
"unit-2": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc),
}
candidates = [
{"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-1
{"id": "c2", "event_date": datetime(2024, 6, 20, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-2
{"id": "c3", "event_date": datetime(2024, 6, 17, 12, 0, 0, tzinfo=timezone.utc)}, # between, near neither
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
# unit-1 should link to c1 only
# unit-2 should link to c2 only
unit1_links = [l for l in links if l[0] == "unit-1"]
unit2_links = [l for l in links if l[0] == "unit-2"]
assert len(unit1_links) == 1
assert unit1_links[0][1] == "c1"
assert len(unit2_links) == 1
assert unit2_links[0][1] == "c2"
def test_mixed_naive_and_aware_datetimes(self):
"""Test that mixed naive/aware datetimes work correctly."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0)} # naive
candidates = [
{"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # aware
]
# Should not raise TypeError
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 1
def test_overflow_near_datetime_min(self):
"""Test overflow protection when unit date is near datetime.min."""
units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)}
candidates = [
{"id": "c1", "event_date": datetime(1, 1, 1, 12, 0, 0, tzinfo=timezone.utc)},
]
# Should not raise OverflowError
links = compute_temporal_links(units, candidates, time_window_hours=48)
assert len(links) == 1
def test_overflow_near_datetime_max(self):
"""Test overflow protection when unit date is near datetime.max."""
units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)}
candidates = [
{"id": "c1", "event_date": datetime(9999, 12, 31, 12, 0, 0, tzinfo=timezone.utc)},
]
# Should not raise OverflowError
links = compute_temporal_links(units, candidates, time_window_hours=48)
assert len(links) == 1
def test_weight_minimum_is_0_3(self):
"""Test that weight doesn't go below 0.3."""
units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)}
candidates = [
# 23 hours apart - should be just within 24h window but low weight
{"id": "c1", "event_date": datetime(2024, 6, 14, 13, 0, 0, tzinfo=timezone.utc)},
]
links = compute_temporal_links(units, candidates, time_window_hours=24)
assert len(links) == 1
assert links[0][3] >= 0.3

View file

@ -12,7 +12,9 @@
"@hindsight/client": "file:../hindsight-clients/typescript", "@hindsight/client": "file:../hindsight-clients/typescript",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
@ -1586,6 +1588,52 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@ -1762,6 +1810,69 @@
} }
} }
}, },
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",

View file

@ -16,7 +16,9 @@
"@hindsight/client": "file:../hindsight-clients/typescript", "@hindsight/client": "file:../hindsight-clients/typescript",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",

View file

@ -13,3 +13,31 @@ export async function GET() {
); );
} }
} }
export async function POST(request: Request) {
try {
const body = await request.json();
const { bank_id } = body;
if (!bank_id) {
return NextResponse.json(
{ error: 'bank_id is required' },
{ status: 400 }
);
}
const response = await sdk.createOrUpdateBank({
client: lowLevelClient,
path: { bank_id },
body: {},
});
return NextResponse.json(response.data, { status: 201 });
} catch (error) {
console.error('Error creating bank:', error);
return NextResponse.json(
{ error: 'Failed to create bank' },
{ status: 500 }
);
}
}

View file

@ -4,6 +4,7 @@ import * as React from 'react';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useBank } from '@/lib/bank-context'; import { useBank } from '@/lib/bank-context';
import { client } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Command, Command,
@ -18,19 +19,105 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { Check, ChevronsUpDown } from 'lucide-react'; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Check, ChevronsUpDown, Plus, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function BankSelectorInner() { function BankSelectorInner() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { currentBank, setCurrentBank, banks } = useBank(); const { currentBank, setCurrentBank, banks, loadBanks } = useBank();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const [newBankId, setNewBankId] = React.useState('');
const [isCreating, setIsCreating] = React.useState(false);
const [createError, setCreateError] = React.useState<string | null>(null);
// Document creation state
const [docDialogOpen, setDocDialogOpen] = React.useState(false);
const [docContent, setDocContent] = React.useState('');
const [docContext, setDocContext] = React.useState('');
const [docEventDate, setDocEventDate] = React.useState('');
const [docDocumentId, setDocDocumentId] = React.useState('');
const [docAsync, setDocAsync] = React.useState(false);
const [isCreatingDoc, setIsCreatingDoc] = React.useState(false);
const [docError, setDocError] = React.useState<string | null>(null);
const sortedBanks = React.useMemo(() => { const sortedBanks = React.useMemo(() => {
return [...banks].sort((a, b) => a.localeCompare(b)); return [...banks].sort((a, b) => a.localeCompare(b));
}, [banks]); }, [banks]);
const handleCreateBank = async () => {
if (!newBankId.trim()) return;
setIsCreating(true);
setCreateError(null);
try {
await client.createBank(newBankId.trim());
await loadBanks();
setCreateDialogOpen(false);
setNewBankId('');
// Navigate to the new bank
setCurrentBank(newBankId.trim());
router.push(`/banks/${newBankId.trim()}?view=data`);
} catch (error) {
setCreateError(error instanceof Error ? error.message : 'Failed to create bank');
} finally {
setIsCreating(false);
}
};
const handleCreateDocument = async () => {
if (!currentBank || !docContent.trim()) return;
setIsCreatingDoc(true);
setDocError(null);
try {
const item: any = { content: docContent };
if (docContext) item.context = docContext;
if (docEventDate) item.event_date = docEventDate;
const params: any = {
bank_id: currentBank,
items: [item],
};
if (docDocumentId) params.document_id = docDocumentId;
if (docAsync) {
await client.retain({ ...params, async: true });
} else {
await client.retain(params);
}
// Reset form and close dialog
setDocDialogOpen(false);
setDocContent('');
setDocContext('');
setDocEventDate('');
setDocDocumentId('');
setDocAsync(false);
// Navigate to documents view to see the new document
router.push(`/banks/${currentBank}?view=documents`);
} catch (error) {
setDocError(error instanceof Error ? error.message : 'Failed to create document');
} finally {
setIsCreatingDoc(false);
}
};
return ( return (
<div className="bg-card text-card-foreground px-5 py-3 border-b-4 border-primary"> <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"> <div className="flex items-center gap-2.5 text-sm">
@ -81,6 +168,163 @@ function BankSelectorInner() {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </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>
{currentBank && (
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-secondary hover:bg-secondary/20 gap-1.5"
onClick={() => setDocDialogOpen(true)}
title="Add document to current bank"
>
<FileText className="h-4 w-4" />
<span>New Document</span>
</Button>
)}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Memory Bank</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
placeholder="Enter bank ID..."
value={newBankId}
onChange={(e) => setNewBankId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isCreating) {
handleCreateBank();
}
}}
autoFocus
/>
{createError && (
<p className="text-sm text-destructive mt-2">{createError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateDialogOpen(false);
setNewBankId('');
setCreateError(null);
}}
>
Cancel
</Button>
<Button
onClick={handleCreateBank}
disabled={isCreating || !newBankId.trim()}
>
{isCreating ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={docDialogOpen} onOpenChange={setDocDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add New Document</DialogTitle>
<p className="text-sm text-muted-foreground">
Add a new document to memory bank: <span className="font-semibold">{currentBank}</span>
</p>
</DialogHeader>
<div className="py-4 space-y-4">
<div>
<label className="font-bold block mb-1 text-sm">Content *</label>
<Textarea
value={docContent}
onChange={(e) => setDocContent(e.target.value)}
placeholder="Enter the document content..."
className="min-h-[150px] resize-y"
autoFocus
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm">Context</label>
<Input
type="text"
value={docContext}
onChange={(e) => setDocContext(e.target.value)}
placeholder="Optional context about this document..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-bold block mb-1 text-sm">Event Date</label>
<Input
type="datetime-local"
value={docEventDate}
onChange={(e) => setDocEventDate(e.target.value)}
/>
</div>
<div>
<label className="font-bold block mb-1 text-sm">Document ID</label>
<Input
type="text"
value={docDocumentId}
onChange={(e) => setDocDocumentId(e.target.value)}
placeholder="Optional document identifier..."
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="async-doc"
checked={docAsync}
onCheckedChange={(checked) => setDocAsync(checked as boolean)}
/>
<label htmlFor="async-doc" className="text-sm cursor-pointer">
Process in background (async)
</label>
</div>
{docError && (
<p className="text-sm text-destructive">{docError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDocDialogOpen(false);
setDocContent('');
setDocContext('');
setDocEventDate('');
setDocDocumentId('');
setDocAsync(false);
setDocError(null);
}}
>
Cancel
</Button>
<Button
onClick={handleCreateDocument}
disabled={isCreatingDoc || !docContent.trim()}
>
{isCreatingDoc ? 'Adding...' : 'Add Document'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
); );

View file

@ -7,8 +7,8 @@ import cytoscape from 'cytoscape';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Copy, Check, X, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileText, Layers } from 'lucide-react';
import { Copy, Check, X, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { MemoryDetailPanel } from './memory-detail-panel';
type FactType = 'world' | 'bank' | 'opinion'; type FactType = 'world' | 'bank' | 'opinion';
type ViewMode = 'graph' | 'table' | 'timeline'; type ViewMode = 'graph' | 'table' | 'timeline';
@ -31,6 +31,8 @@ export function DataView({ factType }: DataViewProps) {
const [selectedChunk, setSelectedChunk] = useState<any>(null); const [selectedChunk, setSelectedChunk] = useState<any>(null);
const [loadingChunk, setLoadingChunk] = useState(false); const [loadingChunk, setLoadingChunk] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [selectedTableMemory, setSelectedTableMemory] = useState<any>(null);
const itemsPerPage = 100; const itemsPerPage = 100;
const cyRef = useRef<any>(null); const cyRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -195,6 +197,23 @@ export function DataView({ factType }: DataViewProps) {
] as any, ] as any,
layout: layouts[layout] || layouts.circle, 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);
}
});
}; };
useEffect(() => { useEffect(() => {
@ -265,7 +284,8 @@ export function DataView({ factType }: DataViewProps) {
</div> </div>
{viewMode === 'graph' && ( {viewMode === 'graph' && (
<div className="relative"> <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="p-4 bg-card border-b-2 border-primary flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Limit nodes:</label> <label className="font-semibold text-card-foreground">Limit nodes:</label>
@ -292,6 +312,9 @@ export function DataView({ factType }: DataViewProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="text-sm text-muted-foreground ml-auto">
Click on a node to view details
</div>
</div> </div>
<div ref={containerRef} className="w-full h-[800px] bg-background" /> <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]"> <div className="absolute top-20 left-5 bg-card p-4 border-2 border-primary rounded-lg shadow-lg max-w-[250px]">
@ -324,11 +347,32 @@ export function DataView({ factType }: DataViewProps) {
</div> </div>
</div> </div>
</div> </div>
{/* Memory Detail Panel for Graph View */}
{selectedGraphNode && (
<div className="w-1/3">
<MemoryDetailPanel
memory={selectedGraphNode}
onClose={() => setSelectedGraphNode(null)}
onViewDocument={(docId) => {
viewDocument(docId);
setSelectedGraphNode(null);
setViewMode('table');
}}
onViewChunk={(chunkId) => {
viewChunk(chunkId);
setSelectedGraphNode(null);
setViewMode('table');
}}
/>
</div>
)}
</div>
)} )}
{viewMode === 'table' && ( {viewMode === 'table' && (
<div className="flex gap-4"> <div className="flex gap-4">
<div className={`transition-all ${selectedDocument || selectedChunk ? 'w-1/2' : 'w-full'}`}> <div className={`transition-all ${selectedDocument || selectedChunk || selectedTableMemory ? 'w-2/3' : 'w-full'}`}>
<div className="px-5 mb-4"> <div className="px-5 mb-4">
<Input <Input
type="text" type="text"
@ -338,20 +382,7 @@ export function DataView({ factType }: DataViewProps) {
className="max-w-2xl" className="max-w-2xl"
/> />
</div> </div>
<div className="overflow-x-auto px-5 pb-5"> <div className="px-5 pb-5">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Text</TableHead>
<TableHead>Context</TableHead>
<TableHead>Occurred</TableHead>
<TableHead>Mentioned</TableHead>
<TableHead>Entities</TableHead>
<TableHead>Document</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.table_rows && data.table_rows.length > 0 ? ( {data.table_rows && data.table_rows.length > 0 ? (
(() => { (() => {
const filteredRows = data.table_rows.filter((row: any) => { const filteredRows = data.table_rows.filter((row: any) => {
@ -368,143 +399,194 @@ export function DataView({ factType }: DataViewProps) {
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, endIndex); const paginatedRows = filteredRows.slice(startIndex, endIndex);
return paginatedRows.map((row: any, idx: number) => { return (
// Format temporal range <>
let occurredDisplay = 'N/A'; <div className="grid gap-3">
if (row.occurred_start && row.occurred_end) { {paginatedRows.map((row: any, idx: number) => {
const start = new Date(row.occurred_start).toLocaleString(); const occurredDisplay = row.occurred_start
const end = new Date(row.occurred_end).toLocaleString(); ? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
occurredDisplay = start === end ? start : `${start} - ${end}`; : null;
} else if (row.date) {
// Fallback to old date field
occurredDisplay = row.date;
}
const mentionedDisplay = row.mentioned_at
? new Date(row.mentioned_at).toLocaleString()
: 'N/A';
return ( return (
<TableRow <div
key={idx} key={row.id || idx}
className={selectedDocument?.id === row.document_id ? 'bg-accent' : ''} onClick={() => setSelectedTableMemory(row)}
className={`group p-4 bg-card border rounded-lg cursor-pointer transition-all hover:border-primary hover:shadow-md ${
selectedTableMemory?.id === row.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
}`}
> >
<TableCell> <div className="flex items-start gap-4">
<div className="flex items-center gap-2"> {/* Main content */}
<span title={row.id} className="text-muted-foreground"> <div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-2 mb-2">
{row.text}
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{occurredDisplay && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{occurredDisplay}
</span>
)}
{row.context && (
<span className="truncate max-w-[200px]" title={row.context}>
{row.context}
</span>
)}
<span className="font-mono opacity-50" title={row.id}>
{row.id.substring(0, 8)}... {row.id.substring(0, 8)}...
</span> </span>
</div>
{row.entities && (
<div className="flex gap-1 mt-2 flex-wrap">
{row.entities.split(', ').slice(0, 5).map((entity: string, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-secondary-foreground">
{entity}
</span>
))}
{row.entities.split(', ').length > 5 && (
<span className="text-[10px] text-muted-foreground">
+{row.entities.split(', ').length - 5}
</span>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{row.document_id && (
<Button <Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.stopPropagation();
viewDocument(row.document_id);
}}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
title="View Document"
>
<FileText className="h-4 w-4" />
</Button>
)}
{row.chunk_id && (
<Button
onClick={(e) => {
e.stopPropagation();
viewChunk(row.chunk_id);
}}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
title="View Chunk"
>
<Layers className="h-4 w-4" />
</Button>
)}
<Button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(row.id); copyToClipboard(row.id);
}} }}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
title="Copy ID"
> >
{copiedId === row.id ? ( {copiedId === row.id ? (
<Check className="h-3 w-3 text-green-600" /> <Check className="h-4 w-4 text-green-600" />
) : ( ) : (
<Copy className="h-3 w-3" /> <Copy className="h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
</TableCell>
<TableCell>{row.text}</TableCell>
<TableCell>{row.context || 'N/A'}</TableCell>
<TableCell>{occurredDisplay}</TableCell>
<TableCell>{mentionedDisplay}</TableCell>
<TableCell>{row.entities || 'None'}</TableCell>
<TableCell>
<div className="flex gap-2">
{row.document_id ? (
<Button
onClick={() => viewDocument(row.document_id)}
size="sm"
variant={selectedDocument?.id === row.document_id ? 'default' : 'outline'}
>
Doc
</Button>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
{row.chunk_id ? (
<Button
onClick={() => viewChunk(row.chunk_id)}
size="sm"
variant={selectedChunk?.chunk_id === row.chunk_id ? 'default' : 'outline'}
>
Chunk
</Button>
) : null}
</div> </div>
</TableCell> </div>
</TableRow>
); );
}); })}
})() </div>
) : (
<TableRow>
<TableCell colSpan={7} className="text-center">
{data.table_rows ? 'No facts match your search' : 'No facts found for this agent and fact type'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination Controls */} {/* Pagination Controls */}
{data.table_rows && data.table_rows.length > 0 && (() => { {totalPages > 1 && (
const filteredRows = data.table_rows.filter((row: any) => { <div className="flex items-center justify-between mt-4 pt-4 border-t">
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query)
);
});
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between px-5 py-4">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredRows.length)} of {filteredRows.length} results Showing {startIndex + 1} to {Math.min(endIndex, filteredRows.length)} of {filteredRows.length}
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-8 w-8 p-0"
> >
Previous <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex items-center gap-2 px-3"> <span className="text-sm px-3">
<span className="text-sm"> {currentPage} / {totalPages}
Page {currentPage} of {totalPages}
</span> </span>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
> >
Next <ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
>
<ChevronsRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
)}
</>
); );
})()} })()
) : (
<div className="text-center py-12 text-muted-foreground">
{data.table_rows ? 'No memories match your search' : 'No memories found'}
</div>
)}
</div> </div>
</div> </div>
{/* Memory Detail Panel for Table View */}
{selectedTableMemory && !selectedDocument && !selectedChunk && (
<div className="w-1/3 pr-5 pb-5">
<MemoryDetailPanel
memory={selectedTableMemory}
onClose={() => setSelectedTableMemory(null)}
onViewDocument={(docId) => {
viewDocument(docId);
setSelectedTableMemory(null);
}}
onViewChunk={(chunkId) => {
viewChunk(chunkId);
setSelectedTableMemory(null);
}}
/>
</div>
)}
{/* Document Detail Panel */} {/* Document Detail Panel */}
{selectedDocument && ( {selectedDocument && (
<div className="w-1/2 pr-5 pb-5"> <div className="w-1/3 pr-5 pb-5">
<div className="bg-card border-2 border-primary rounded-lg p-4 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto"> <div className="bg-card border-2 border-primary rounded-lg p-4 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
@ -571,7 +653,7 @@ export function DataView({ factType }: DataViewProps) {
{/* Chunk Detail Panel */} {/* Chunk Detail Panel */}
{selectedChunk && ( {selectedChunk && (
<div className="w-1/2 pr-5 pb-5"> <div className="w-1/3 pr-5 pb-5">
<div className="bg-card border-2 border-primary rounded-lg p-4 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto"> <div className="bg-card border-2 border-primary rounded-lg p-4 sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
@ -1000,87 +1082,12 @@ function TimelineView({ data, onViewDocument }: { data: any; onViewDocument: (id
{/* Detail Panel */} {/* Detail Panel */}
{selectedItem && ( {selectedItem && (
<div className="w-1/3"> <div className="w-1/3">
<div className="bg-card border border-border rounded-lg p-3 sticky top-4 max-h-[600px] overflow-y-auto"> <MemoryDetailPanel
<div className="flex justify-between items-start mb-3"> memory={selectedItem}
<h3 className="text-sm font-semibold text-card-foreground">Details</h3> onClose={() => setSelectedItem(null)}
<Button onViewDocument={onViewDocument}
variant="ghost" compact
size="sm" />
onClick={() => setSelectedItem(null)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">Text</div>
<div className="text-xs whitespace-pre-wrap">{selectedItem.text}</div>
</div>
{selectedItem.context && (
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">Context</div>
<div className="text-xs">{selectedItem.context}</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">Start</div>
<div className="text-xs">
{selectedItem.occurred_start
? new Date(selectedItem.occurred_start).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false
})
: 'N/A'}
</div>
</div>
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">End</div>
<div className="text-xs">
{selectedItem.occurred_end
? new Date(selectedItem.occurred_end).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false
})
: 'N/A'}
</div>
</div>
</div>
{selectedItem.entities && (
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">Entities</div>
<div className="flex gap-1 flex-wrap">
{selectedItem.entities.split(', ').map((entity: string, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-secondary-foreground">
{entity}
</span>
))}
</div>
</div>
)}
<div className="p-2 bg-muted rounded">
<div className="text-[10px] font-medium text-muted-foreground uppercase mb-0.5">ID</div>
<div className="text-[10px] font-mono text-muted-foreground truncate">{selectedItem.id}</div>
</div>
{selectedItem.document_id && (
<Button
onClick={() => onViewDocument(selectedItem.document_id)}
className="w-full h-7 text-xs"
variant="outline"
size="sm"
>
View Document
</Button>
)}
</div>
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -3,11 +3,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { client } from '@/lib/api'; import { client } from '@/lib/api';
import { useBank } from '@/lib/bank-context'; import { useBank } from '@/lib/bank-context';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
@ -18,16 +15,6 @@ export function DocumentsView() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
// Add memory form state
const [showAddMemory, setShowAddMemory] = useState(false);
const [content, setContent] = useState('');
const [context, setContext] = useState('');
const [eventDate, setEventDate] = useState('');
const [documentId, setDocumentId] = useState('');
const [async, setAsync] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [submitResult, setSubmitResult] = useState<string | null>(null);
// Document view panel state // Document view panel state
const [selectedDocument, setSelectedDocument] = useState<any>(null); const [selectedDocument, setSelectedDocument] = useState<any>(null);
const [loadingDocument, setLoadingDocument] = useState(false); const [loadingDocument, setLoadingDocument] = useState(false);
@ -70,50 +57,6 @@ export function DocumentsView() {
} }
}; };
const submitMemory = async () => {
if (!currentBank || !content) {
alert('Please enter content');
return;
}
setSubmitLoading(true);
setSubmitResult(null);
try {
const item: any = { content };
if (context) item.context = context;
if (eventDate) item.event_date = eventDate;
const params: any = {
bank_id: currentBank,
items: [item],
};
if (documentId) params.document_id = documentId;
let data: any;
if (async) {
data = await client.retain({ ...params, async: true });
} else {
data = await client.retain(params);
}
setSubmitResult(data.message as string);
setContent('');
setContext('');
setEventDate('');
setDocumentId('');
// Refresh documents list
loadDocuments();
} catch (error) {
console.error('Error submitting memory:', error);
setSubmitResult('Error: ' + (error as Error).message);
} finally {
setSubmitLoading(false);
}
};
// Auto-load documents when component mounts // Auto-load documents when component mounts
useEffect(() => { useEffect(() => {
if (currentBank) { if (currentBank) {
@ -123,94 +66,6 @@ export function DocumentsView() {
return ( return (
<div> <div>
{/* Retain Memory Section */}
<div className="mb-6 bg-card rounded-lg border-2 border-primary overflow-hidden">
<Button
variant="ghost"
onClick={() => setShowAddMemory(!showAddMemory)}
className="w-full flex items-center justify-between p-4 h-auto"
>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-card-foreground">Retain Memory</span>
<span className="text-sm text-muted-foreground">Add new memories to this memory bank</span>
</div>
{showAddMemory ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</Button>
{showAddMemory && (
<div className="p-4 border-t border-border bg-background">
<div className="max-w-3xl">
<div className="mb-4">
<label className="font-bold block mb-1 text-card-foreground">Content *</label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Enter the memory content..."
className="min-h-[100px] resize-y"
/>
</div>
<div className="mb-4">
<label className="font-bold block mb-1 text-card-foreground">Context</label>
<Input
type="text"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="Optional context about this memory..."
/>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="font-bold block mb-1 text-card-foreground">Event Date</label>
<Input
type="datetime-local"
value={eventDate}
onChange={(e) => setEventDate(e.target.value)}
/>
</div>
<div>
<label className="font-bold block mb-1 text-card-foreground">Document ID</label>
<Input
type="text"
value={documentId}
onChange={(e) => setDocumentId(e.target.value)}
placeholder="Optional document identifier..."
/>
</div>
</div>
<div className="mb-4">
<div className="flex items-center gap-2">
<Checkbox
id="async-docs"
checked={async}
onCheckedChange={(checked) => setAsync(checked as boolean)}
/>
<label htmlFor="async-docs" className="text-sm text-card-foreground cursor-pointer">
Async (process in background)
</label>
</div>
</div>
<Button
onClick={submitMemory}
disabled={submitLoading || !content}
>
{submitLoading ? 'Retaining...' : 'Retain Memory'}
</Button>
{submitResult && (
<div className={`mt-4 p-3 rounded-lg border-2 text-sm ${submitResult.startsWith('Error') ? 'bg-destructive/10 border-destructive text-destructive' : 'bg-primary/10 border-primary text-primary'}`}>
{submitResult}
</div>
)}
</div>
</div>
)}
</div>
{/* Documents List Section */} {/* Documents List Section */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">

View file

@ -0,0 +1,161 @@
'use client';
import { Button } from '@/components/ui/button';
import { Copy, Check, X } from 'lucide-react';
import { useState } from 'react';
interface MemoryDetailPanelProps {
memory: any;
onClose: () => void;
onViewDocument?: (documentId: string) => void;
onViewChunk?: (chunkId: string) => void;
compact?: boolean;
}
export function MemoryDetailPanel({
memory,
onClose,
onViewDocument,
onViewChunk,
compact = false,
}: MemoryDetailPanelProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedId(text);
setTimeout(() => setCopiedId(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
if (!memory) return null;
const padding = compact ? 'p-3' : 'p-4';
const titleSize = compact ? 'text-sm' : 'text-lg';
const labelSize = compact ? 'text-[10px]' : 'text-xs';
const textSize = compact ? 'text-xs' : 'text-sm';
const gap = compact ? 'space-y-2' : 'space-y-4';
return (
<div className={`bg-card border-2 border-primary rounded-lg ${padding} sticky top-4 max-h-[calc(100vh-120px)] overflow-y-auto`}>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className={`${titleSize} font-bold text-card-foreground`}>Memory Details</h3>
{!compact && (
<p className="text-sm text-muted-foreground">Full memory content and metadata</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className={compact ? 'h-6 w-6 p-0' : 'h-8 w-8 p-0'}
>
<X className={compact ? 'h-3 w-3' : 'h-4 w-4'} />
</Button>
</div>
<div className={gap}>
{/* Full Text */}
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-1`}>Full Text</div>
<div className={`${textSize} whitespace-pre-wrap`}>{memory.text}</div>
</div>
{/* Context */}
{memory.context && (
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-1`}>Context</div>
<div className={textSize}>{memory.context}</div>
</div>
)}
{/* Dates */}
<div className="grid grid-cols-2 gap-2">
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-1`}>Occurred</div>
<div className={textSize}>
{memory.occurred_start
? new Date(memory.occurred_start).toLocaleString()
: 'N/A'}
</div>
</div>
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-1`}>Mentioned</div>
<div className={textSize}>
{memory.mentioned_at
? new Date(memory.mentioned_at).toLocaleString()
: 'N/A'}
</div>
</div>
</div>
{/* Entities */}
{memory.entities && (
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-2`}>Entities</div>
<div className="flex flex-wrap gap-1">
{memory.entities.split(', ').map((entity: string, i: number) => (
<span
key={i}
className={`${compact ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-1'} rounded bg-secondary text-secondary-foreground`}
>
{entity}
</span>
))}
</div>
</div>
)}
{/* ID */}
<div className={`${compact ? 'p-2' : 'p-3'} bg-muted rounded-lg`}>
<div className={`${labelSize} font-bold text-muted-foreground uppercase mb-1`}>Memory ID</div>
<div className="flex items-center gap-2">
<span className={`${compact ? 'text-[10px]' : 'text-sm'} font-mono break-all`}>{memory.id}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 flex-shrink-0"
onClick={() => copyToClipboard(memory.id)}
>
{copiedId === memory.id ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* Document/Chunk buttons */}
{(memory.document_id || memory.chunk_id) && (
<div className={`flex gap-2 ${compact ? 'pt-1' : ''}`}>
{memory.document_id && onViewDocument && (
<Button
onClick={() => onViewDocument(memory.document_id)}
size="sm"
variant="outline"
className={`flex-1 ${compact ? 'h-7 text-xs' : ''}`}
>
View Document
</Button>
)}
{memory.chunk_id && onViewChunk && (
<Button
onClick={() => onViewChunk(memory.chunk_id)}
size="sm"
variant="outline"
className={`flex-1 ${compact ? 'h-7 text-xs' : ''}`}
>
View Chunk
</Button>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
@ -149,6 +151,7 @@ export function SearchDebugView() {
trace: data.trace || null, trace: data.trace || null,
loading: false, loading: false,
currentRetrievalFactType: defaultFactType, currentRetrievalFactType: defaultFactType,
currentPhase: 'final',
}); });
} catch (error) { } catch (error) {
console.error('Error running search:', error); console.error('Error running search:', error);
@ -651,52 +654,43 @@ export function SearchDebugView() {
{/* Phase Controls */} {/* Phase Controls */}
{pane.trace && ( {pane.trace && (
<div className="p-2.5 bg-card border-b-2 border-primary flex gap-3"> <div className="p-2.5 bg-card border-b-2 border-primary">
<label className="flex items-center gap-1.5 cursor-pointer"> <RadioGroup
<input value={pane.currentPhase}
type="radio" onValueChange={(value) => updatePane(pane.id, { currentPhase: value as Phase })}
name={`phase-${pane.id}`} className="flex gap-3"
checked={pane.currentPhase === 'retrieval'} >
onChange={() => updatePane(pane.id, { currentPhase: 'retrieval' })} <div className="flex items-center gap-1.5">
/> <RadioGroupItem value="retrieval" id={`phase-retrieval-${pane.id}`} />
<span className="text-xs font-bold">1. Retrieval</span> <Label htmlFor={`phase-retrieval-${pane.id}`} className="text-xs font-bold cursor-pointer">
</label> 1. Retrieval
<label className="flex items-center gap-1.5 cursor-pointer"> </Label>
<input </div>
type="radio" <div className="flex items-center gap-1.5">
name={`phase-${pane.id}`} <RadioGroupItem value="rrf" id={`phase-rrf-${pane.id}`} />
checked={pane.currentPhase === 'rrf'} <Label htmlFor={`phase-rrf-${pane.id}`} className="text-xs font-bold cursor-pointer">
onChange={() => updatePane(pane.id, { currentPhase: 'rrf' })} 2. RRF Merge
/> </Label>
<span className="text-xs font-bold">2. RRF Merge</span> </div>
</label> <div className="flex items-center gap-1.5">
<label className="flex items-center gap-1.5 cursor-pointer"> <RadioGroupItem value="rerank" id={`phase-rerank-${pane.id}`} />
<input <Label htmlFor={`phase-rerank-${pane.id}`} className="text-xs font-bold cursor-pointer">
type="radio" 3. Reranking
name={`phase-${pane.id}`} </Label>
checked={pane.currentPhase === 'rerank'} </div>
onChange={() => updatePane(pane.id, { currentPhase: 'rerank' })} <div className="flex items-center gap-1.5">
/> <RadioGroupItem value="json" id={`phase-json-${pane.id}`} />
<span className="text-xs font-bold">3. Reranking</span> <Label htmlFor={`phase-json-${pane.id}`} className="text-xs font-bold cursor-pointer">
</label> 4. Raw JSON
<label className="flex items-center gap-1.5 cursor-pointer"> </Label>
<input </div>
type="radio" <div className="flex items-center gap-1.5">
name={`phase-${pane.id}`} <RadioGroupItem value="final" id={`phase-final-${pane.id}`} />
checked={pane.currentPhase === 'json'} <Label htmlFor={`phase-final-${pane.id}`} className="text-xs font-bold cursor-pointer">
onChange={() => updatePane(pane.id, { currentPhase: 'json' })} 5. Final Results
/> </Label>
<span className="text-xs font-bold">4. Raw JSON</span> </div>
</label> </RadioGroup>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name={`phase-${pane.id}`}
checked={pane.currentPhase === 'final'}
onChange={() => updatePane(pane.id, { currentPhase: 'final' })}
/>
<span className="text-xs font-bold">5. Final Results</span>
</label>
</div> </div>
)} )}

View file

@ -2,142 +2,121 @@
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dialog({ const Dialog = DialogPrimitive.Root
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ const DialogTrigger = DialogPrimitive.Trigger
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ const DialogPortal = DialogPrimitive.Portal
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ const DialogClose = DialogPrimitive.Close
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ const DialogOverlay = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Overlay>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { >(({ className, ...props }, ref) => (
return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" ref={ref}
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
/> />
) ))
} DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
function DialogContent({ const DialogContent = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Content>,
children, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
showCloseButton = true, >(({ className, children, ...props }, ref) => (
...props <DialogPortal>
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" ref={ref}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close <X className="h-4 w-4" />
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) ))
} DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { const DialogHeader = ({
return ( className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col space-y-1.5 text-center sm:text-left",
className className
)} )}
{...props} {...props}
/> />
) )
} DialogHeader.displayName = "DialogHeader"
function DialogTitle({ const DialogFooter = ({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.HTMLAttributes<HTMLDivElement>) => (
return ( <div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" ref={ref}
className={cn("text-lg leading-none font-semibold", className)} className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props} {...props}
/> />
) ))
} DialogTitle.displayName = DialogPrimitive.Title.displayName
function DialogDescription({ const DialogDescription = React.forwardRef<
className, React.ElementRef<typeof DialogPrimitive.Description>,
...props React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
}: React.ComponentProps<typeof DialogPrimitive.Description>) { >(({ className, ...props }, ref) => (
return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" ref={ref}
className={cn("text-muted-foreground text-sm", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) ))
} DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
Dialog, Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogOverlay,
DialogClose,
DialogTrigger, DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} }

View file

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View file

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View file

@ -28,6 +28,16 @@ export class ControlPlaneClient {
return this.fetchApi<{ banks: any[] }>('/api/banks', { cache: 'no-store' as RequestCache }); return this.fetchApi<{ banks: any[] }>('/api/banks', { cache: 'no-store' as RequestCache });
} }
/**
* Create a new bank
*/
async createBank(bankId: string) {
return this.fetchApi<{ bank_id: string }>('/api/banks', {
method: 'POST',
body: JSON.stringify({ bank_id: bankId }),
});
}
/** /**
* Recall memories * Recall memories
*/ */

View file

@ -913,12 +913,12 @@ class BenchmarkRunner:
# Only clear on first item for shared agent_id # Only clear on first item for shared agent_id
clear_this_agent = (i == 1) clear_this_agent = (i == 1)
# Check if we should skip this item (filln mode) # Check if we should skip this item (fill mode - skip if already in results file)
item_id = self.dataset.get_item_id(item)
if filln: if filln:
has_data = await self._agent_has_data(item_agent_id) if item_id in existing_item_ids:
if has_data: console.print(f"\n[bold blue]Item {i}/{len(items)}[/bold blue] (ID: {item_id})")
console.print(f"\n[bold blue]Item {i}/{len(items)}[/bold blue] (ID: {self.dataset.get_item_id(item)})") console.print(f" [yellow]⊘[/yellow] Skipping - already has results in output file")
console.print(f" [yellow]⊘[/yellow] Skipping - agent '{item_agent_id}' already has indexed data")
continue continue
result = await self.process_single_item( result = await self.process_single_item(
@ -981,12 +981,11 @@ class BenchmarkRunner:
item_id = self.dataset.get_item_id(item) item_id = self.dataset.get_item_id(item)
item_agent_id = f"{agent_id}_{item_id}" item_agent_id = f"{agent_id}_{item_id}"
# Check if we should skip this item (filln mode) # Check if we should skip this item (fill mode - skip if already in results file)
if filln: if filln:
has_data = await self._agent_has_data(item_agent_id) if item_id in existing_item_ids:
if has_data:
console.print(f"\n[bold blue]Item {i}/{len(items)}[/bold blue] (ID: {item_id})") console.print(f"\n[bold blue]Item {i}/{len(items)}[/bold blue] (ID: {item_id})")
console.print(f" [yellow]⊘[/yellow] Skipping - agent '{item_agent_id}' already has indexed data") console.print(f" [yellow]⊘[/yellow] Skipping - already has results in output file")
return None return None
# Process the item # Process the item

View file

@ -551,7 +551,7 @@ if __name__ == "__main__":
parser.add_argument( parser.add_argument(
"--fill", "--fill",
action="store_true", action="store_true",
help="Only process questions where the agent has no indexed data yet (for resuming interrupted runs)" help="Only process questions not already in results file (for resuming interrupted runs)"
) )
parser.add_argument( parser.add_argument(
"--question-id", "--question-id",

View file

@ -30,6 +30,10 @@ Associate retained content with a document:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Retain with document ID # Retain with document ID
client.retain( client.retain(
bank_id="my-bank", bank_id="my-bank",
@ -40,7 +44,7 @@ client.retain(
# Batch retain for a document # Batch retain for a document
client.retain_batch( client.retain_batch(
bank_id="my-bank", bank_id="my-bank",
contents=[ items=[
{"content": "Item 1: Product launch delayed to Q2"}, {"content": "Item 1: Product launch delayed to Q2"},
{"content": "Item 2: New hiring targets announced"}, {"content": "Item 2: New hiring targets announced"},
{"content": "Item 3: Budget approved for ML team"} {"content": "Item 3: Budget approved for ML team"}
@ -60,24 +64,22 @@ with open("notes.txt") as f:
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
import { HindsightClient } from '@hindsight/client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
// Retain with document ID // Retain with document ID
await client.retain({ await client.retain('my-bank', 'Alice presented the Q4 roadmap...', {
bankId: 'my-bank', document_id: 'meeting-2024-03-15'
content: 'Alice presented the Q4 roadmap...',
documentId: 'meeting-2024-03-15'
}); });
// Batch retain // Batch retain
await client.retainBatch({ await client.retainBatch('my-bank', [
bankId: 'my-bank',
contents: [
{ content: 'Item 1: Product launch delayed to Q2' }, { content: 'Item 1: Product launch delayed to Q2' },
{ content: 'Item 2: New hiring targets announced' }, { content: 'Item 2: New hiring targets announced' },
{ content: 'Item 3: Budget approved for ML team' } { content: 'Item 3: Budget approved for ML team' }
], ], { documentId: 'meeting-2024-03-15' });
documentId: 'meeting-2024-03-15'
});
``` ```
</TabItem> </TabItem>
@ -120,19 +122,15 @@ client.retain(
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Original // Original
await client.retain({ await client.retain('my-bank', 'Project deadline: March 31', {
bankId: 'my-bank', document_id: 'project-plan'
content: 'Project deadline: March 31',
documentId: 'project-plan'
}); });
// Update // Update
await client.retain({ await client.retain('my-bank', 'Project deadline: April 15 (extended)', {
bankId: 'my-bank', document_id: 'project-plan'
content: 'Project deadline: April 15 (extended)',
documentId: 'project-plan'
}); });
``` ```
@ -158,16 +156,23 @@ View all documents in a memory bank:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# List all documents # Using the low-level API
documents = client.list_documents(bank_id="my-bank") from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
for doc in documents: config = Configuration(host="http://localhost:8888")
print(f"{doc['id']}: {doc['memory_count']} memories") api_client = ApiClient(config)
print(f" Created: {doc['created_at']}") api = DefaultApi(api_client)
print(f" Updated: {doc['updated_at']}")
# List all documents
response = api.list_documents(bank_id="my-bank")
for doc in response.items:
print(f"{doc.id}: {doc.memory_unit_count} memories")
print(f" Created: {doc.created_at}")
# With pagination # With pagination
documents = client.list_documents( response = api.list_documents(
bank_id="my-bank", bank_id="my-bank",
limit=50, limit=50,
offset=0 offset=0
@ -177,17 +182,21 @@ documents = client.list_documents(
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
import { sdk, createClient, createConfig } from '@hindsight/client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all documents // List all documents
const documents = await client.listDocuments({ const response = await sdk.listDocuments({
bankId: 'my-bank' client: apiClient,
path: { bank_id: 'my-bank' }
}); });
documents.forEach(doc => { for (const doc of response.data.items) {
console.log(`${doc.id}: ${doc.memoryCount} memories`); console.log(`${doc.id}: ${doc.memory_unit_count} memories`);
console.log(` Created: ${doc.createdAt}`); console.log(` Created: ${doc.created_at}`);
console.log(` Updated: ${doc.updatedAt}`); }
});
``` ```
</TabItem> </TabItem>
@ -206,52 +215,36 @@ hindsight documents list my-bank --limit 50
## Get Document Details ## Get Document Details
Retrieve a specific document with its memories: Retrieve a specific document with its content:
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Get document # Get document
doc = client.get_document( doc = api.get_document(
bank_id="my-bank", bank_id="my-bank",
document_id="meeting-2024-03-15" document_id="meeting-2024-03-15"
) )
print(f"Document: {doc['id']}") print(f"Document: {doc.id}")
print(f"Original text: {doc['original_text'][:200]}...") print(f"Original text: {doc.original_text[:200]}...")
print(f"Memories: {doc['memory_count']}") print(f"Memories: {doc.memory_unit_count}")
# Get with memories
doc = client.get_document(
bank_id="my-bank",
document_id="meeting-2024-03-15",
include_memories=True
)
for memory in doc['memories']:
print(f" - {memory['text']}")
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Get document // Get document
const doc = await client.getDocument({ const doc = await sdk.getDocument({
bankId: 'my-bank', client: apiClient,
documentId: 'meeting-2024-03-15' path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
}); });
console.log(`Document: ${doc.id}`); console.log(`Document: ${doc.data.id}`);
console.log(`Memories: ${doc.memoryCount}`); console.log(`Original text: ${doc.data.original_text.substring(0, 200)}...`);
console.log(`Memories: ${doc.data.memory_unit_count}`);
// Get with memories
const withMemories = await client.getDocument({
bankId: 'my-bank',
documentId: 'meeting-2024-03-15',
includeMemories: true
});
``` ```
</TabItem> </TabItem>
@ -260,9 +253,6 @@ const withMemories = await client.getDocument({
```bash ```bash
# Get document # Get document
hindsight documents get my-bank meeting-2024-03-15 hindsight documents get my-bank meeting-2024-03-15
# With memories
hindsight documents get my-bank meeting-2024-03-15 --include-memories
``` ```
</TabItem> </TabItem>
@ -277,31 +267,31 @@ Remove a document and all its memories:
```python ```python
# Delete document (removes all associated memories) # Delete document (removes all associated memories)
client.delete_document( api.delete_document(
bank_id="my-bank", bank_id="my-bank",
document_id="old-meeting" document_id="old-meeting"
) )
# Bulk delete # Bulk delete
for doc_id in ["old-1", "old-2", "old-3"]: for doc_id in ["old-1", "old-2", "old-3"]:
client.delete_document(bank_id="my-bank", document_id=doc_id) api.delete_document(bank_id="my-bank", document_id=doc_id)
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Delete document // Delete document
await client.deleteDocument({ await sdk.deleteDocument({
bankId: 'my-bank', client: apiClient,
documentId: 'old-meeting' path: { bank_id: 'my-bank', document_id: 'old-meeting' }
}); });
// Bulk delete // Bulk delete
for (const docId of ['old-1', 'old-2', 'old-3']) { for (const docId of ['old-1', 'old-2', 'old-3']) {
await client.deleteDocument({ await sdk.deleteDocument({
bankId: 'my-bank', client: apiClient,
documentId: docId path: { bank_id: 'my-bank', document_id: docId }
}); });
} }
``` ```
@ -327,11 +317,12 @@ hindsight documents delete my-bank old-meeting --confirm
"id": "meeting-2024-03-15", "id": "meeting-2024-03-15",
"bank_id": "my-bank", "bank_id": "my-bank",
"original_text": "Alice presented the Q4 roadmap...", "original_text": "Alice presented the Q4 roadmap...",
"content_hash": "sha256:abc123...", "memory_unit_count": 12,
"memory_count": 12,
"created_at": "2024-03-15T14:00:00Z", "created_at": "2024-03-15T14:00:00Z",
"updated_at": "2024-03-15T14:00:00Z", "retain_params": {
"metadata": {} "context": "team meeting",
"event_date": "2024-03-15"
}
} }
``` ```
@ -339,7 +330,12 @@ hindsight documents delete my-bank old-meeting --confirm
### Meeting Notes ### Meeting Notes
<Tabs>
<TabItem value="python" label="Python">
```python ```python
from datetime import date
# Store meeting notes with date-based IDs # Store meeting notes with date-based IDs
client.retain( client.retain(
bank_id="team-memory", bank_id="team-memory",
@ -348,10 +344,21 @@ client.retain(
) )
``` ```
</TabItem>
</Tabs>
### Documentation ### Documentation
<Tabs>
<TabItem value="python" label="Python">
```python ```python
from pathlib import Path
# Store docs with version tracking # Store docs with version tracking
docs_dir = Path("docs")
version = "1.0"
for file in docs_dir.glob("*.md"): for file in docs_dir.glob("*.md"):
client.retain( client.retain(
bank_id="docs-memory", bank_id="docs-memory",
@ -360,8 +367,14 @@ for file in docs_dir.glob("*.md"):
) )
``` ```
</TabItem>
</Tabs>
### Conversation History ### Conversation History
<Tabs>
<TabItem value="python" label="Python">
```python ```python
# Store chat history with session IDs # Store chat history with session IDs
client.retain( client.retain(
@ -371,6 +384,9 @@ client.retain(
) )
``` ```
</TabItem>
</Tabs>
## Next Steps ## Next Steps
- [**Entities**](./entities) — Track people, places, and concepts - [**Entities**](./entities) — Track people, places, and concepts

View file

@ -17,13 +17,34 @@ Make sure you've [installed Hindsight](./installation) and understand [how retai
When you retain information, Hindsight automatically identifies and tracks entities: When you retain information, Hindsight automatically identifies and tracks entities:
<Tabs>
<TabItem value="python" label="Python">
```python ```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.retain( client.retain(
bank_id="my-bank", bank_id="my-bank",
content="Alice works at Google in Mountain View. She specializes in TensorFlow." content="Alice works at Google in Mountain View. She specializes in TensorFlow."
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
import { HindsightClient } from '@hindsight/client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice works at Google in Mountain View. She specializes in TensorFlow.');
```
</TabItem>
</Tabs>
**Entities extracted:** **Entities extracted:**
- **Alice** (person) - **Alice** (person)
- **Google** (organization) - **Google** (organization)
@ -46,14 +67,22 @@ Get all entities tracked in a memory bank:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all entities # List all entities
entities = client.list_entities(bank_id="my-bank") response = api.list_entities(bank_id="my-bank")
for entity in entities: for entity in response.items:
print(f"{entity['name']}: {entity['mention_count']} mentions") print(f"{entity.canonical_name}: {entity.mention_count} mentions")
# List with filters # List with pagination
entities = client.list_entities( response = api.list_entities(
bank_id="my-bank", bank_id="my-bank",
limit=50, limit=50,
offset=0 offset=0
@ -63,21 +92,26 @@ entities = client.list_entities(
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
import { sdk, createClient, createConfig } from '@hindsight/client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all entities // List all entities
const entities = await client.listEntities({ const response = await sdk.listEntities({
bankId: 'my-bank' client: apiClient,
path: { bank_id: 'my-bank' }
}); });
entities.forEach(e => { for (const entity of response.data.items) {
console.log(`${e.name}: ${e.mentionCount} mentions`); console.log(`${entity.canonical_name}: ${entity.mention_count} mentions`);
}); }
// List with filters // List with pagination
const filtered = await client.listEntities({ const paginated = await sdk.listEntities({
bankId: 'my-bank', client: apiClient,
limit: 50, path: { bank_id: 'my-bank' },
offset: 0 query: { limit: 50, offset: 0 }
}); });
``` ```
@ -103,58 +137,39 @@ Retrieve detailed information about a specific entity:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Get entity state (observations + related facts) # Get entity details with observations
entity = client.get_entity( entity = api.get_entity(
bank_id="my-bank", bank_id="my-bank",
entity_id="entity-uuid" entity_id="entity-uuid"
) )
print(f"Entity: {entity['name']}") print(f"Entity: {entity.canonical_name}")
print(f"First seen: {entity['first_seen']}") print(f"First seen: {entity.first_seen}")
print(f"Mentions: {entity['mention_count']}") print(f"Mentions: {entity.mention_count}")
# Observations (synthesized summaries) # Observations (synthesized summaries)
for obs in entity['observations']: for obs in entity.observations:
print(f" - {obs['text']}") print(f" - {obs.text}")
# Include related facts
entity = client.get_entity(
bank_id="my-bank",
entity_id="entity-uuid",
include_facts=True,
max_facts=20
)
for fact in entity['facts']:
print(f" [{fact['occurred_at']}] {fact['text']}")
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Get entity state // Get entity details
const entity = await client.getEntity({ const entity = await sdk.getEntity({
bankId: 'my-bank', client: apiClient,
entityId: 'entity-uuid' path: { bank_id: 'my-bank', entity_id: 'entity-uuid' }
}); });
console.log(`Entity: ${entity.name}`); console.log(`Entity: ${entity.data.canonical_name}`);
console.log(`First seen: ${entity.firstSeen}`); console.log(`First seen: ${entity.data.first_seen}`);
console.log(`Mentions: ${entity.mentionCount}`); console.log(`Mentions: ${entity.data.mention_count}`);
// Observations // Observations
entity.observations.forEach(obs => { for (const obs of entity.data.observations) {
console.log(` - ${obs.text}`); console.log(` - ${obs.text}`);
}); }
// Include related facts
const withFacts = await client.getEntity({
bankId: 'my-bank',
entityId: 'entity-uuid',
includeFacts: true,
maxFacts: 20
});
``` ```
</TabItem> </TabItem>
@ -163,9 +178,6 @@ const withFacts = await client.getEntity({
```bash ```bash
# Get entity details # Get entity details
hindsight entities get my-bank entity-uuid hindsight entities get my-bank entity-uuid
# With related facts
hindsight entities get my-bank entity-uuid --include-facts
``` ```
</TabItem> </TabItem>
@ -185,50 +197,30 @@ Observations are high-level summaries automatically synthesized from multiple fa
Observations are generated in the background after retaining information. Observations are generated in the background after retaining information.
## Search Entities ## Regenerate Observations
Find entities by name or related terms: Force regeneration of entity observations:
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Search by name # Regenerate observations for an entity
entities = client.search_entities( api.regenerate_entity_observations(
bank_id="my-bank", bank_id="my-bank",
query="Alice" entity_id="entity-uuid"
)
# Fuzzy matching handles variations
entities = client.search_entities(
bank_id="my-bank",
query="Alic" # Matches "Alice", "Alicia", etc.
) )
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Search by name // Regenerate observations
const entities = await client.searchEntities({ await sdk.regenerateEntityObservations({
bankId: 'my-bank', client: apiClient,
query: 'Alice' path: { bank_id: 'my-bank', entity_id: 'entity-uuid' }
}); });
// Fuzzy matching
const fuzzy = await client.searchEntities({
bankId: 'my-bank',
query: 'Alic'
});
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Search entities
hindsight entities search my-bank "Alice"
``` ```
</TabItem> </TabItem>
@ -239,7 +231,6 @@ hindsight entities search my-bank "Alice"
```json ```json
{ {
"id": "entity-uuid", "id": "entity-uuid",
"name": "Alice Chen",
"canonical_name": "Alice Chen", "canonical_name": "Alice Chen",
"first_seen": "2024-01-15T10:30:00Z", "first_seen": "2024-01-15T10:30:00Z",
"last_seen": "2024-03-20T14:22:00Z", "last_seen": "2024-03-20T14:22:00Z",
@ -247,7 +238,7 @@ hindsight entities search my-bank "Alice"
"observations": [ "observations": [
{ {
"text": "Alice is a software engineer at Google specializing in ML", "text": "Alice is a software engineer at Google specializing in ML",
"created_at": "2024-03-20T15:00:00Z" "mentioned_at": "2024-03-20T15:00:00Z"
} }
] ]
} }

View file

@ -2,14 +2,14 @@
sidebar_position: 6 sidebar_position: 6
--- ---
# Memory bank Identity # Memory Bank Identity
Configure memory bank personality, background, and behavior. Configure memory bank personality, background, and behavior.
import Tabs from '@theme/Tabs'; import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem'; import TabItem from '@theme/TabItem';
## Creating an Memory bank ## Creating a Memory Bank
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
@ -19,8 +19,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888") client = Hindsight(base_url="http://localhost:8888")
client.create_agent( client.create_bank(
agent_id="my-agent", bank_id="my-bank",
name="Research Assistant", name="Research Assistant",
background="I am a research assistant specializing in machine learning", background="I am a research assistant specializing in machine learning",
personality={ personality={
@ -38,11 +38,11 @@ client.create_agent(
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```typescript ```typescript
import { OpenAPI, ManagementService } from '@hindsight/client'; import { HindsightClient } from '@hindsight/client';
OpenAPI.BASE = 'http://localhost:8888'; const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await ManagementService.createAgentApiAgentsAgentIdPut('my-agent', { await client.createBank('my-bank', {
name: 'Research Assistant', name: 'Research Assistant',
background: 'I am a research assistant specializing in machine learning', background: 'I am a research assistant specializing in machine learning',
personality: { personality: {
@ -61,10 +61,10 @@ await ManagementService.createAgentApiAgentsAgentIdPut('my-agent', {
```bash ```bash
# Set background # Set background
hindsight agent background my-agent "I am a research assistant specializing in ML" hindsight agent background my-bank "I am a research assistant specializing in ML"
# Set personality # Set personality
hindsight memory bank personality my-agent \ hindsight agent personality my-bank \
--openness 0.8 \ --openness 0.8 \
--conscientiousness 0.7 \ --conscientiousness 0.7 \
--extraversion 0.5 \ --extraversion 0.5 \
@ -90,72 +90,83 @@ Each trait is scored 0.0 to 1.0:
### How Traits Affect Behavior ### How Traits Affect Behavior
**Openness** influences how the memory bank weighs new vs. established ideas: **Openness** influences how the bank weighs new vs. established ideas:
```python ```python
# High openness agent # High openness bank
"Let's try this new framework—it looks promising!" "Let's try this new framework—it looks promising!"
# Low openness agent # Low openness bank
"Let's stick with the proven solution we know works." "Let's stick with the proven solution we know works."
``` ```
**Conscientiousness** affects structure and thoroughness: **Conscientiousness** affects structure and thoroughness:
```python ```python
# High conscientiousness agent # High conscientiousness bank
"Here's a detailed, step-by-step analysis..." "Here's a detailed, step-by-step analysis..."
# Low conscientiousness agent # Low conscientiousness bank
"Quick take: this should work, let's try it." "Quick take: this should work, let's try it."
``` ```
**Extraversion** shapes collaboration preferences: **Extraversion** shapes collaboration preferences:
```python ```python
# High extraversion agent # High extraversion bank
"We should get the team together to discuss this." "We should get the team together to discuss this."
# Low extraversion agent # Low extraversion bank
"I'll analyze this independently and share my findings." "I'll analyze this independently and share my findings."
``` ```
**Agreeableness** affects how disagreements are handled: **Agreeableness** affects how disagreements are handled:
```python ```python
# High agreeableness agent # High agreeableness bank
"That's a valid point. Perhaps we can find a middle ground..." "That's a valid point. Perhaps we can find a middle ground..."
# Low agreeableness agent # Low agreeableness bank
"Actually, the data doesn't support that conclusion." "Actually, the data doesn't support that conclusion."
``` ```
**Neuroticism** influences risk assessment: **Neuroticism** influences risk assessment:
```python ```python
# High neuroticism agent # High neuroticism bank
"We should consider what could go wrong here..." "We should consider what could go wrong here..."
# Low neuroticism agent # Low neuroticism bank
"The risks seem manageable, let's proceed." "The risks seem manageable, let's proceed."
``` ```
## Background ## Background
The background is a first-person narrative providing agent context: The background is a first-person narrative providing bank context:
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
client.create_agent( client.create_bank(
agent_id="financial-advisor", bank_id="financial-advisor",
background="""I am a conservative financial advisor with 20 years of experience. background="""I am a conservative financial advisor with 20 years of experience.
I prioritize capital preservation over aggressive growth. I prioritize capital preservation over aggressive growth.
I have seen multiple market crashes and believe in diversification.""" I have seen multiple market crashes and believe in diversification."""
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
await client.createBank('financial-advisor', {
background: `I am a conservative financial advisor with 20 years of experience.
I prioritize capital preservation over aggressive growth.
I have seen multiple market crashes and believe in diversification.`
});
```
</TabItem> </TabItem>
</Tabs> </Tabs>
@ -164,91 +175,43 @@ Background influences:
- Perspective in responses - Perspective in responses
- Opinion formation context - Opinion formation context
### Merging Background ## Getting Bank Profile
New background information is merged intelligently:
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Original background # Using the low-level API
client.create_agent( from hindsight_client_api import ApiClient, Configuration
agent_id="assistant", from hindsight_client_api.api import DefaultApi
background="I am a helpful AI assistant"
)
# Add more context (merged, not replaced) config = Configuration(host="http://localhost:8888")
client.update_background( api_client = ApiClient(config)
agent_id="assistant", api = DefaultApi(api_client)
background="I specialize in Python programming"
)
# Result: "I am a helpful AI assistant. I specialize in Python programming." profile = api.get_bank_profile("my-bank")
print(f"Name: {profile.name}")
print(f"Background: {profile.background}")
print(f"Personality: {profile.personality}")
``` ```
</TabItem> </TabItem>
</Tabs> <TabItem value="node" label="Node.js">
Merging rules: ```typescript
- **Conflicts**: New overwrites old const profile = await client.getBankProfile('my-bank');
- **Additions**: Non-conflicting info is added
- **Normalization**: "You are..." → "I am..."
## Getting Memory bank Profile console.log(`Name: ${profile.name}`);
console.log(`Background: ${profile.background}`);
<Tabs> console.log(`Personality:`, profile.personality);
<TabItem value="python" label="Python">
```python
profile = client.get_profile(agent_id="my-agent")
print(f"Background: {profile['background']}")
print(f"Personality: {profile['personality']}")
``` ```
</TabItem> </TabItem>
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory bank profile my-agent hindsight agent profile my-bank
```
</TabItem>
</Tabs>
## Updating Personality
<Tabs>
<TabItem value="python" label="Python">
```python
client.update_personality(
agent_id="my-agent",
openness=0.9,
conscientiousness=0.8
)
```
</TabItem>
</Tabs>
## Listing Memory banks
<Tabs>
<TabItem value="python" label="Python">
```python
memory banks = client.list_agents()
for agent in memory banks:
print(agent["agent_id"])
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight agent list
``` ```
</TabItem> </TabItem>
@ -256,7 +219,7 @@ hindsight agent list
## Default Values ## Default Values
If not specified, memory banks use neutral defaults: If not specified, banks use neutral defaults:
```python ```python
{ {
@ -287,9 +250,9 @@ Common personality configurations:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Customer support agent # Customer support bank
client.create_agent( client.create_bank(
agent_id="support", bank_id="support",
background="I am a friendly customer support agent", background="I am a friendly customer support agent",
personality={ personality={
"openness": 0.5, "openness": 0.5,
@ -301,9 +264,9 @@ client.create_agent(
} }
) )
# Code reviewer agent # Code reviewer bank
client.create_agent( client.create_bank(
agent_id="reviewer", bank_id="reviewer",
background="I am a thorough code reviewer focused on quality", background="I am a thorough code reviewer focused on quality",
personality={ personality={
"openness": 0.4, # Prefers proven patterns "openness": 0.4, # Prefers proven patterns
@ -316,21 +279,70 @@ client.create_agent(
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Customer support bank
await client.createBank('support', {
background: 'I am a friendly customer support agent',
personality: {
openness: 0.5,
conscientiousness: 0.7,
extraversion: 0.6,
agreeableness: 0.9,
neuroticism: 0.3,
bias_strength: 0.4
}
});
// Code reviewer bank
await client.createBank('reviewer', {
background: 'I am a thorough code reviewer focused on quality',
personality: {
openness: 0.4,
conscientiousness: 0.9,
extraversion: 0.3,
agreeableness: 0.4,
neuroticism: 0.5,
bias_strength: 0.6
}
});
```
</TabItem> </TabItem>
</Tabs> </Tabs>
## Memory bank Isolation ## Bank Isolation
Each agent has: Each bank has:
- **Separate memories** — memory banks don't share memories - **Separate memories** — banks don't share memories
- **Own personality** — traits are per-agent - **Own personality** — traits are per-bank
- **Independent opinions** — formed from their own experiences - **Independent opinions** — formed from their own experiences
```python <Tabs>
# Store to agent A <TabItem value="python" label="Python">
client.store(agent_id="agent-a", content="Python is great")
# Memory bank B doesn't see it ```python
results = client.search(agent_id="agent-b", query="Python") # Store to bank A
client.retain(bank_id="bank-a", content="Python is great")
# Bank B doesn't see it
results = client.recall(bank_id="bank-b", query="Python")
# Returns empty # Returns empty
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Store to bank A
await client.retain('bank-a', 'Python is great');
// Bank B doesn't see it
const results = await client.recall('bank-b', 'Python');
// Returns empty
```
</TabItem>
</Tabs>

View file

@ -31,84 +31,40 @@ For large content batches, use async mode to avoid timeouts:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Start async batch retain # Start async batch retain
operation = client.retain_batch_async( result = client.retain_batch(
bank_id="my-bank", bank_id="my-bank",
contents=[ items=[
{"content": doc1_text}, {"content": doc1_text},
{"content": doc2_text}, {"content": doc2_text},
# ... hundreds or thousands of documents # ... hundreds or thousands of documents
] ],
async_=True # Enable async mode
) )
print(f"Operation ID: {operation['operation_id']}") print(f"Operation ID: {result.get('operation_id')}")
print(f"Status: {operation['status']}") # 'pending' or 'running'
# Check status
status = client.get_operation(
bank_id="my-bank",
operation_id=operation['operation_id']
)
print(f"Status: {status['status']}") # 'pending', 'running', 'completed', 'failed'
print(f"Progress: {status['progress']}/{status['total']}")
# Wait for completion
import time
while True:
status = client.get_operation(bank_id="my-bank", operation_id=operation['operation_id'])
if status['status'] in ['completed', 'failed']:
break
print(f"Progress: {status['progress']}/{status['total']}")
time.sleep(5)
if status['status'] == 'completed':
print(f"Created {len(status['result']['memory_ids'])} memories")
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
import { HindsightClient } from '@hindsight/client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
// Start async batch retain // Start async batch retain
const operation = await client.retainBatchAsync({ const result = await client.retainBatch('my-bank', [
bankId: 'my-bank',
contents: [
{ content: doc1Text }, { content: doc1Text },
{ content: doc2Text }, { content: doc2Text },
// ... hundreds or thousands of documents // ... hundreds or thousands of documents
] ], { async: true });
});
console.log(`Operation ID: ${operation.operationId}`); console.log(`Operation ID: ${result.operation_id}`);
console.log(`Status: ${operation.status}`);
// Check status
const status = await client.getOperation({
bankId: 'my-bank',
operationId: operation.operationId
});
console.log(`Status: ${status.status}`);
console.log(`Progress: ${status.progress}/${status.total}`);
// Wait for completion
async function waitForOperation(bankId, operationId) {
while (true) {
const status = await client.getOperation({ bankId, operationId });
if (['completed', 'failed'].includes(status.status)) {
return status;
}
console.log(`Progress: ${status.progress}/${status.total}`);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
const finalStatus = await waitForOperation('my-bank', operation.operationId);
if (finalStatus.status === 'completed') {
console.log(`Created ${finalStatus.result.memoryIds.length} memories`);
}
``` ```
</TabItem> </TabItem>
@ -119,12 +75,6 @@ if (finalStatus.status === 'completed') {
hindsight retain my-bank --files docs/*.md --async hindsight retain my-bank --files docs/*.md --async
# Returns operation ID: op-abc123... # Returns operation ID: op-abc123...
# Check status
hindsight operations get my-bank op-abc123
# Watch progress
hindsight operations watch my-bank op-abc123
``` ```
</TabItem> </TabItem>
@ -138,54 +88,45 @@ View all operations for a memory bank:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all operations # List all operations
operations = client.list_operations(bank_id="my-bank") response = api.list_operations(bank_id="my-bank")
for op in operations: for op in response.items:
print(f"{op['operation_id']}: {op['type']} - {op['status']}") print(f"{op.id}: {op.task_type} - {op.status}")
if op['status'] == 'running': print(f" Items: {op.items_count}")
print(f" Progress: {op['progress']}/{op['total']}") if op.error_message:
print(f" Error: {op.error_message}")
# Filter by status
pending = client.list_operations(
bank_id="my-bank",
status="pending"
)
running = client.list_operations(
bank_id="my-bank",
status="running"
)
# With pagination
operations = client.list_operations(
bank_id="my-bank",
limit=50,
offset=0
)
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
import { sdk, createClient, createConfig } from '@hindsight/client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all operations // List all operations
const operations = await client.listOperations({ const response = await sdk.listOperations({
bankId: 'my-bank' client: apiClient,
path: { bank_id: 'my-bank' }
}); });
operations.forEach(op => { for (const op of response.data.items) {
console.log(`${op.operationId}: ${op.type} - ${op.status}`); console.log(`${op.id}: ${op.task_type} - ${op.status}`);
if (op.status === 'running') { console.log(` Items: ${op.items_count}`);
console.log(` Progress: ${op.progress}/${op.total}`); if (op.error_message) {
console.log(` Error: ${op.error_message}`);
}
} }
});
// Filter by status
const pending = await client.listOperations({
bankId: 'my-bank',
status: 'pending'
});
``` ```
</TabItem> </TabItem>
@ -214,39 +155,42 @@ Stop a running or pending operation:
```python ```python
# Cancel operation # Cancel operation
client.cancel_operation( api.cancel_operation(
bank_id="my-bank", bank_id="my-bank",
operation_id="op-abc123" operation_id="op-abc123"
) )
# Cancel all pending operations # Cancel all pending operations
operations = client.list_operations(bank_id="my-bank", status="pending") response = api.list_operations(bank_id="my-bank")
for op in operations: for op in response.items:
client.cancel_operation(bank_id="my-bank", operation_id=op['operation_id']) if op.status == "pending":
api.cancel_operation(bank_id="my-bank", operation_id=op.id)
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```javascript ```typescript
// Cancel operation // Cancel operation
await client.cancelOperation({ await sdk.cancelOperation({
bankId: 'my-bank', client: apiClient,
operationId: 'op-abc123' path: { bank_id: 'my-bank', operation_id: 'op-abc123' }
}); });
// Cancel all pending // Cancel all pending
const pending = await client.listOperations({ const ops = await sdk.listOperations({
bankId: 'my-bank', client: apiClient,
status: 'pending' path: { bank_id: 'my-bank' }
}); });
for (const op of pending) { for (const op of ops.data.items) {
await client.cancelOperation({ if (op.status === 'pending') {
bankId: 'my-bank', await sdk.cancelOperation({
operationId: op.operationId client: apiClient,
path: { bank_id: 'my-bank', operation_id: op.id }
}); });
} }
}
``` ```
</TabItem> </TabItem>
@ -285,17 +229,14 @@ hindsight operations cancel my-bank --all-pending
```json ```json
{ {
"operation_id": "op-abc123", "id": "op-abc123",
"bank_id": "my-bank", "bank_id": "my-bank",
"type": "batch_retain", "task_type": "batch_retain",
"status": "running", "status": "completed",
"progress": 450, "items_count": 1000,
"total": 1000, "document_id": "batch-001",
"created_at": "2024-03-15T10:00:00Z", "created_at": "2024-03-15T10:00:00Z",
"started_at": "2024-03-15T10:00:05Z", "error_message": null
"completed_at": null,
"error": null,
"result": null
} }
``` ```
@ -303,39 +244,70 @@ hindsight operations cancel my-bank --all-pending
### Polling ### Polling
<Tabs>
<TabItem value="python" label="Python">
```python ```python
import time import time
def wait_for_operation(client, bank_id, operation_id, poll_interval=5): def wait_for_operations(api, bank_id, poll_interval=5):
"""Wait for all pending/running operations to complete."""
while True: while True:
status = client.get_operation(bank_id=bank_id, operation_id=operation_id) response = api.list_operations(bank_id=bank_id)
if status['status'] == 'completed': pending_or_running = [
return status['result'] op for op in response.items
elif status['status'] == 'failed': if op.status in ['pending', 'running']
raise Exception(f"Operation failed: {status['error']}") ]
elif status['status'] == 'cancelled':
raise Exception("Operation was cancelled") if not pending_or_running:
print("All operations completed!")
break
for op in pending_or_running:
print(f" {op.id}: {op.status} ({op.items_count} items)")
print(f"Progress: {status['progress']}/{status['total']}")
time.sleep(poll_interval) time.sleep(poll_interval)
# Use it # Use it
result = wait_for_operation(client, "my-bank", op_id) wait_for_operations(api, "my-bank")
print(f"Created {len(result['memory_ids'])} memories")
``` ```
### Webhooks (Coming Soon) </TabItem>
<TabItem value="node" label="Node.js">
```python ```typescript
# Configure webhook for operation completion async function waitForOperations(apiClient: any, bankId: string, pollInterval = 5000) {
client.configure_webhook( while (true) {
bank_id="my-bank", const response = await sdk.listOperations({
url="https://myapp.com/webhooks/hindsight", client: apiClient,
events=["operation.completed", "operation.failed"] path: { bank_id: bankId }
) });
const pendingOrRunning = response.data.items.filter(
(op: any) => ['pending', 'running'].includes(op.status)
);
if (pendingOrRunning.length === 0) {
console.log('All operations completed!');
break;
}
for (const op of pendingOrRunning) {
console.log(` ${op.id}: ${op.status} (${op.items_count} items)`);
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
}
// Use it
await waitForOperations(apiClient, 'my-bank');
``` ```
</TabItem>
</Tabs>
## Performance Tips ## Performance Tips
**Use async for large batches:** **Use async for large batches:**
@ -343,11 +315,11 @@ client.configure_webhook(
- Async: > 100 items or > 100KB - Async: > 100 items or > 100KB
**Monitor progress:** **Monitor progress:**
- Check `progress` / `total` fields - Check `items_count` field
- Poll every 5-10 seconds - Poll every 5-10 seconds
**Handle failures:** **Handle failures:**
- Check `error` field for details - Check `error_message` field for details
- Retry with exponential backoff - Retry with exponential backoff
- Break large batches into smaller chunks - Break large batches into smaller chunks

View file

@ -19,8 +19,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888") client = Hindsight(base_url="http://localhost:8888")
results = client.search( results = client.recall(
agent_id="my-agent", bank_id="my-bank",
query="What does Alice do?" query="What does Alice do?"
) )
@ -32,16 +32,13 @@ for r in results:
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```typescript ```typescript
import { OpenAPI, SearchService } from '@hindsight/client'; import { HindsightClient } from '@hindsight/client';
OpenAPI.BASE = 'http://localhost:8888'; const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
const results = await SearchService.searchApiSearchPost({ const results = await client.recall('my-bank', 'What does Alice do?');
agent_id: 'my-agent',
query: 'What does Alice do?'
});
for (const r of results.results) { for (const r of results) {
console.log(`${r.text} (score: ${r.weight})`); console.log(`${r.text} (score: ${r.weight})`);
} }
``` ```
@ -50,7 +47,7 @@ for (const r of results.results) {
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory search my-agent "What does Alice do?" hindsight memory search my-bank "What does Alice do?"
``` ```
</TabItem> </TabItem>
@ -61,27 +58,85 @@ hindsight memory search my-agent "What does Alice do?"
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `query` | string | required | Natural language query | | `query` | string | required | Natural language query |
| `top_k` | int | 10 | Maximum results to return | | `types` | list | all | Filter: `world`, `agent`, `opinion` |
| `budget` | Budget | MID | Budget level: LOW (100), MID (300), HIGH (600) nodes | | `budget` | string | "mid" | Budget level: "low", "mid", "high" |
| `fact_type` | list | all | Filter: `world`, `agent`, `opinion` |
| `max_tokens` | int | 4096 | Token budget for results | | `max_tokens` | int | 4096 | Token budget for results |
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
from hindsight_api.engine.memory_engine import Budget
results = client.recall( results = client.recall(
bank_id="my-agent", bank_id="my-bank",
query="What does Alice do?", query="What does Alice do?",
top_k=20, types=["world", "agent"],
budget=Budget.HIGH, budget="high",
fact_type=["world", "agent"],
max_tokens=8000 max_tokens=8000
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const results = await client.recall('my-bank', 'What does Alice do?', {
budget: 'high',
maxTokens: 8000
});
```
</TabItem>
</Tabs>
## Full-Featured Search
For more control, use the full-featured recall method:
<Tabs>
<TabItem value="python" label="Python">
```python
# Full response with trace info
response = client.recall_memories(
bank_id="my-bank",
query="What does Alice do?",
types=["world", "agent"],
budget="high",
max_tokens=8000,
trace=True,
include_entities=True,
max_entity_tokens=500
)
# Access results
for r in response["results"]:
print(f"{r['text']} (score: {r['weight']:.2f})")
# Access entity observations (if include_entities=True)
if "entities" in response:
for entity in response["entities"]:
print(f"Entity: {entity['name']}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Full response with trace info
const response = await client.recallMemories('my-bank', {
query: 'What does Alice do?',
types: ['world', 'agent'],
budget: 'high',
maxTokens: 8000,
trace: true
});
// Access results
for (const r of response.results) {
console.log(`${r.text} (score: ${r.weight})`);
}
```
</TabItem> </TabItem>
</Tabs> </Tabs>
@ -94,17 +149,17 @@ Hindsight automatically detects time expressions and activates temporal search:
```python ```python
# These queries activate temporal-graph retrieval # These queries activate temporal-graph retrieval
results = client.search(agent_id="my-agent", query="What did Alice do last spring?") results = client.recall(bank_id="my-bank", query="What did Alice do last spring?")
results = client.search(agent_id="my-agent", query="What happened in June?") results = client.recall(bank_id="my-bank", query="What happened in June?")
results = client.search(agent_id="my-agent", query="Events from last year") results = client.recall(bank_id="my-bank", query="Events from last year")
``` ```
</TabItem> </TabItem>
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory search my-agent "What did Alice do last spring?" hindsight memory search my-bank "What did Alice do last spring?"
hindsight memory search my-agent "What happened between March and May?" hindsight memory search my-bank "What happened between March and May?"
``` ```
</TabItem> </TabItem>
@ -129,31 +184,31 @@ Search specific memory networks:
```python ```python
# Only world facts (objective information) # Only world facts (objective information)
world_facts = client.search_memories( world_facts = client.recall(
agent_id="my-agent", bank_id="my-bank",
query="Where does Alice work?", query="Where does Alice work?",
fact_type=["world"] types=["world"]
) )
# Only agent facts (memory bank's own experiences) # Only agent facts (memory bank's own experiences)
agent_facts = client.search_memories( agent_facts = client.recall(
agent_id="my-agent", bank_id="my-bank",
query="What have I recommended?", query="What have I recommended?",
fact_type=["agent"] types=["agent"]
) )
# Only opinions (formed beliefs) # Only opinions (formed beliefs)
opinions = client.search_memories( opinions = client.recall(
agent_id="my-agent", bank_id="my-bank",
query="What do I think about Python?", query="What do I think about Python?",
fact_type=["opinion"] types=["opinion"]
) )
# World and agent facts (exclude opinions) # World and agent facts (exclude opinions)
facts = client.search_memories( facts = client.recall(
agent_id="my-agent", bank_id="my-bank",
query="What happened?", query="What happened?",
fact_type=["world", "agent"] types=["world", "agent"]
) )
``` ```
@ -161,8 +216,8 @@ facts = client.search_memories(
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory search my-agent "Python" --fact-type opinion hindsight memory search my-bank "Python" --fact-type opinion
hindsight memory search my-agent "Alice" --fact-type world,agent hindsight memory search my-bank "Alice" --fact-type world,agent
``` ```
</TabItem> </TabItem>
@ -225,16 +280,31 @@ graph LR
The `budget` parameter controls graph traversal depth: The `budget` parameter controls graph traversal depth:
- **Budget.LOW (100 nodes)**: Fast, shallow search — good for simple lookups - **"low" (100 nodes)**: Fast, shallow search — good for simple lookups
- **Budget.MID (300 nodes)**: Balanced — default for most queries - **"mid" (300 nodes)**: Balanced — default for most queries
- **Budget.HIGH (600 nodes)**: Deep exploration — finds indirect connections - **"high" (600 nodes)**: Deep exploration — finds indirect connections
<Tabs>
<TabItem value="python" label="Python">
```python ```python
from hindsight_api.engine.memory_engine import Budget
# Quick lookup # Quick lookup
results = client.recall(bank_id="my-agent", query="Alice's email", budget=Budget.LOW) results = client.recall(bank_id="my-bank", query="Alice's email", budget="low")
# Deep exploration # Deep exploration
results = client.recall(bank_id="my-agent", query="How are Alice and Bob connected?", budget=Budget.HIGH) results = client.recall(bank_id="my-bank", query="How are Alice and Bob connected?", budget="high")
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Quick lookup
const results = await client.recall('my-bank', "Alice's email", { budget: 'low' });
// Deep exploration
const deep = await client.recall('my-bank', 'How are Alice and Bob connected?', { budget: 'high' });
```
</TabItem>
</Tabs>

View file

@ -2,7 +2,7 @@
sidebar_position: 3 sidebar_position: 3
--- ---
# Think # Reflect
Generate personality-aware responses using retrieved memories. Generate personality-aware responses using retrieved memories.
@ -19,38 +19,35 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888") client = Hindsight(base_url="http://localhost:8888")
answer = client.think( response = client.reflect(
agent_id="my-agent", bank_id="my-bank",
query="What should I know about Alice?" query="What should I know about Alice?"
) )
print(answer["text"]) print(response["answer"])
``` ```
</TabItem> </TabItem>
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```typescript ```typescript
import { OpenAPI, ReasoningService } from '@hindsight/client'; import { HindsightClient } from '@hindsight/client';
OpenAPI.BASE = 'http://localhost:8888'; const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
const response = await ReasoningService.thinkApiThinkPost({ const response = await client.reflect('my-bank', 'What should I know about Alice?');
agent_id: 'my-agent',
query: 'What should I know about Alice?'
});
console.log(response.text); console.log(response.answer);
``` ```
</TabItem> </TabItem>
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory think my-agent "What should I know about Alice?" hindsight memory think my-bank "What should I know about Alice?"
# Verbose output shows reasoning and sources # Verbose output shows reasoning and sources
hindsight memory think my-agent "What should I know about Alice?" -v hindsight memory think my-bank "What should I know about Alice?" -v
``` ```
</TabItem> </TabItem>
@ -60,26 +57,21 @@ hindsight memory think my-agent "What should I know about Alice?" -v
```python ```python
{ {
"text": "Alice is a software engineer at Google who joined last year...", "answer": "Alice is a software engineer at Google who joined last year...",
"based_on": { "facts_used": [
"world": [ {"text": "Alice works at Google", "weight": 0.95, "id": "..."},
{"text": "Alice works at Google", "weight": 0.95, "id": "..."}
],
"agent": [],
"opinion": [
{"text": "Alice is very competent", "weight": 0.82, "id": "..."} {"text": "Alice is very competent", "weight": 0.82, "id": "..."}
] ],
},
"new_opinions": [ "new_opinions": [
{"text": "Alice would be good for the ML project", "confidence": 0.75} {"text": "Alice would be good for the ML project", "id": "..."}
] ]
} }
``` ```
| Field | Description | | Field | Description |
|-------|-------------| |-------|-------------|
| `text` | Generated response | | `answer` | Generated response |
| `based_on` | Memories used, grouped by type | | `facts_used` | Memories used in generation |
| `new_opinions` | New opinions formed during reasoning | | `new_opinions` | New opinions formed during reasoning |
## Parameters ## Parameters
@ -87,26 +79,35 @@ hindsight memory think my-agent "What should I know about Alice?" -v
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `query` | string | required | Question or prompt | | `query` | string | required | Question or prompt |
| `budget` | Budget | LOW | Budget level: LOW (100), MID (300), HIGH (600) nodes | | `budget` | string | "low" | Budget level: "low", "mid", "high" |
| `top_k` | int | 10 | Max memories to retrieve | | `context` | string | None | Additional context for the query |
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
from hindsight_api.engine.memory_engine import Budget response = client.reflect(
bank_id="my-bank",
answer = client.reflect(
bank_id="my-agent",
query="What do you think about remote work?", query="What do you think about remote work?",
budget=Budget.MID budget="mid",
context="We're considering a hybrid work policy"
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const response = await client.reflect('my-bank', 'What do you think about remote work?', {
budget: 'mid',
context: "We're considering a hybrid work policy"
});
```
</TabItem> </TabItem>
</Tabs> </Tabs>
## What Think Does ## What Reflect Does
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@ -115,10 +116,10 @@ sequenceDiagram
participant M as Memory Store participant M as Memory Store
participant L as LLM participant L as LLM
C->>A: think("What about Alice?") C->>A: reflect("What about Alice?")
A->>M: Search all networks A->>M: Search all networks
M-->>A: World + Memory bank + Opinion facts M-->>A: World + Bank + Opinion facts
A->>A: Load memory bank personality A->>A: Load bank personality
A->>L: Generate with personality context A->>L: Generate with personality context
L-->>A: Response + new opinions L-->>A: Response + new opinions
A->>M: Store new opinions A->>M: Store new opinions
@ -126,28 +127,28 @@ sequenceDiagram
``` ```
1. **Retrieves** relevant memories from all three networks 1. **Retrieves** relevant memories from all three networks
2. **Loads** memory bank personality (Big Five traits + background) 2. **Loads** bank personality (Big Five traits + background)
3. **Generates** response influenced by personality 3. **Generates** response influenced by personality
4. **Forms opinions** if the query warrants it 4. **Forms opinions** if the query warrants it
5. **Returns** response with sources and any new opinions 5. **Returns** response with sources and any new opinions
## Opinion Formation ## Opinion Formation
Think can form new opinions based on evidence: Reflect can form new opinions based on evidence:
<Tabs> <Tabs>
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
answer = client.think( response = client.reflect(
agent_id="my-agent", bank_id="my-bank",
query="What do you think about Python vs JavaScript for data science?" query="What do you think about Python vs JavaScript for data science?"
) )
# Response might include: # Response might include:
# text: "Based on what I know about data science workflows..." # answer: "Based on what I know about data science workflows..."
# new_opinions: [ # new_opinions: [
# {"text": "Python is better for data science", "confidence": 0.85} # {"text": "Python is better for data science", "id": "..."}
# ] # ]
``` ```
@ -158,9 +159,9 @@ New opinions are automatically stored and influence future responses.
## Personality Influence ## Personality Influence
The memory bank's personality affects Think responses: The bank's personality affects reflect responses:
| Trait | Effect on Think | | Trait | Effect on Reflect |
|-------|-----------------| |-------|-----------------|
| High **Openness** | More willing to consider new ideas | | High **Openness** | More willing to consider new ideas |
| High **Conscientiousness** | More structured, methodical responses | | High **Conscientiousness** | More structured, methodical responses |
@ -168,10 +169,13 @@ The memory bank's personality affects Think responses:
| High **Agreeableness** | More diplomatic, harmony-seeking | | High **Agreeableness** | More diplomatic, harmony-seeking |
| High **Neuroticism** | More risk-aware, cautious | | High **Neuroticism** | More risk-aware, cautious |
<Tabs>
<TabItem value="python" label="Python">
```python ```python
# Create a memory bank with specific personality # Create a bank with specific personality
client.create_agent( client.create_bank(
agent_id="cautious-advisor", bank_id="cautious-advisor",
background="I am a risk-aware financial advisor", background="I am a risk-aware financial advisor",
personality={ personality={
"openness": 0.3, "openness": 0.3,
@ -181,28 +185,69 @@ client.create_agent(
} }
) )
# Think responses will reflect this personality # Reflect responses will reflect this personality
answer = client.think( response = client.reflect(
agent_id="cautious-advisor", bank_id="cautious-advisor",
query="Should I invest in crypto?" query="Should I invest in crypto?"
) )
# Response will likely emphasize risks and caution # Response will likely emphasize risks and caution
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Create a bank with specific personality
await client.createBank('cautious-advisor', {
background: 'I am a risk-aware financial advisor',
personality: {
openness: 0.3,
conscientiousness: 0.9,
neuroticism: 0.8,
bias_strength: 0.7
}
});
// Reflect responses will reflect this personality
const response = await client.reflect('cautious-advisor', 'Should I invest in crypto?');
```
</TabItem>
</Tabs>
## Using Sources ## Using Sources
The `based_on` field shows which memories informed the response: The `facts_used` field shows which memories informed the response:
<Tabs>
<TabItem value="python" label="Python">
```python ```python
answer = client.think(agent_id="my-agent", query="Tell me about Alice") response = client.reflect(bank_id="my-bank", query="Tell me about Alice")
print("Response:", answer["text"]) print("Response:", response["answer"])
print("\nBased on:") print("\nBased on:")
for fact in answer["based_on"]["world"]: for fact in response.get("facts_used", []):
print(f" - {fact['text']} (relevance: {fact['weight']:.2f})") print(f" - {fact['text']} (relevance: {fact['weight']:.2f})")
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const response = await client.reflect('my-bank', 'Tell me about Alice');
console.log('Response:', response.answer);
console.log('\nBased on:');
for (const fact of response.facts_used || []) {
console.log(` - ${fact.text} (relevance: ${fact.weight.toFixed(2)})`);
}
```
</TabItem>
</Tabs>
This enables: This enables:
- **Transparency** — users see why the memory bank said something - **Transparency** — users see why the bank said something
- **Verification** — check if the response is grounded in facts - **Verification** — check if the response is grounded in facts
- **Debugging** — understand retrieval quality - **Debugging** — understand retrieval quality

View file

@ -45,8 +45,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888") client = Hindsight(base_url="http://localhost:8888")
client.store( client.retain(
agent_id="my-agent", bank_id="my-bank",
content="Alice works at Google as a software engineer" content="Alice works at Google as a software engineer"
) )
``` ```
@ -55,21 +55,18 @@ client.store(
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```typescript ```typescript
import { OpenAPI, MemoryStorageService } from '@hindsight/client'; import { HindsightClient } from '@hindsight/client';
OpenAPI.BASE = 'http://localhost:8888'; const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await MemoryStorageService.putApiPutPost({ await client.retain('my-bank', 'Alice works at Google as a software engineer');
agent_id: 'my-agent',
content: 'Alice works at Google as a software engineer'
});
``` ```
</TabItem> </TabItem>
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory put my-agent "Alice works at Google as a software engineer" hindsight memory put my-bank "Alice works at Google as a software engineer"
``` ```
</TabItem> </TabItem>
@ -83,19 +80,29 @@ Add context and event dates for better retrieval:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
client.store( client.retain(
agent_id="my-agent", bank_id="my-bank",
content="Alice got promoted to senior engineer", content="Alice got promoted to senior engineer",
context="career update", context="career update",
event_date="2024-03-15T10:00:00Z" timestamp="2024-03-15T10:00:00Z"
) )
``` ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
await client.retain('my-bank', 'Alice got promoted to senior engineer', {
context: 'career update',
timestamp: '2024-03-15T10:00:00Z'
});
```
</TabItem> </TabItem>
<TabItem value="cli" label="CLI"> <TabItem value="cli" label="CLI">
```bash ```bash
hindsight memory put my-agent "Alice got promoted" \ hindsight memory put my-bank "Alice got promoted" \
--context "career update" \ --context "career update" \
--event-date "2024-03-15" --event-date "2024-03-15"
``` ```
@ -103,7 +110,7 @@ hindsight memory put my-agent "Alice got promoted" \
</TabItem> </TabItem>
</Tabs> </Tabs>
The `event_date` enables temporal queries like "What happened last spring?" The `timestamp` enables temporal queries like "What happened last spring?"
## Batch Ingestion ## Batch Ingestion
@ -113,8 +120,8 @@ Store multiple memories in a single request:
<TabItem value="python" label="Python"> <TabItem value="python" label="Python">
```python ```python
client.store_batch( client.retain_batch(
agent_id="my-agent", bank_id="my-bank",
items=[ items=[
{"content": "Alice works at Google", "context": "career"}, {"content": "Alice works at Google", "context": "career"},
{"content": "Bob is a data scientist at Meta", "context": "career"}, {"content": "Bob is a data scientist at Meta", "context": "career"},
@ -128,14 +135,11 @@ client.store_batch(
<TabItem value="node" label="Node.js"> <TabItem value="node" label="Node.js">
```typescript ```typescript
await MemoryStorageService.batchApiMemoriesBatchPost({ await client.retainBatch('my-bank', [
agent_id: 'my-agent',
items: [
{ content: 'Alice works at Google', context: 'career' }, { content: 'Alice works at Google', context: 'career' },
{ content: 'Bob is a data scientist at Meta', context: 'career' } { content: 'Bob is a data scientist at Meta', context: 'career' },
], { content: 'Alice and Bob are friends', context: 'relationship' }
document_id: 'conversation_001' ], { documentId: 'conversation_001' });
});
``` ```
</TabItem> </TabItem>
@ -150,13 +154,13 @@ The `document_id` groups related memories for later management.
```bash ```bash
# Single file # Single file
hindsight memory put-files my-agent document.txt hindsight memory put-files my-bank document.txt
# Multiple files # Multiple files
hindsight memory put-files my-agent doc1.txt doc2.md notes.txt hindsight memory put-files my-bank doc1.txt doc2.md notes.txt
# With document ID # With document ID
hindsight memory put-files my-agent report.pdf --document-id "q4-report" hindsight memory put-files my-bank report.pdf --document-id "q4-report"
``` ```
</TabItem> </TabItem>
@ -191,15 +195,28 @@ For large batches, use async ingestion:
```python ```python
# Start async ingestion # Start async ingestion
operation = client.store_batch_async( result = client.retain_batch(
agent_id="my-agent", bank_id="my-bank",
items=[...large batch...], items=[...large batch...],
document_id="large-doc" document_id="large-doc",
async_=True
) )
# Check status # Result contains operation_id for tracking
status = client.get_operation(operation["operation_id"]) print(result["operation_id"])
print(status["status"]) # "pending", "processing", "completed", "failed" ```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Start async ingestion
const result = await client.retainBatch('my-bank', largeItems, {
documentId: 'large-doc',
async: true
});
console.log(result.operation_id);
``` ```
</TabItem> </TabItem>
@ -211,5 +228,5 @@ print(status["status"]) # "pending", "processing", "completed", "failed"
|----|-------| |----|-------|
| Include context for better retrieval | Store raw unstructured dumps | | Include context for better retrieval | Store raw unstructured dumps |
| Use document_id to group related content | Mix unrelated content in one batch | | Use document_id to group related content | Mix unrelated content in one batch |
| Add event_date for temporal queries | Omit dates if time matters | | Add timestamp for temporal queries | Omit dates if time matters |
| Store conversations as they happen | Wait to batch everything | | Store conversations as they happen | Wait to batch everything |

View file

@ -63,7 +63,7 @@ class Server:
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: Optional[int] = None, port: Optional[int] = None,
mcp_enabled: bool = False, mcp_enabled: bool = False,
log_level: str = "warning", log_level: str = "info",
): ):
""" """
Initialize the Hindsight server. Initialize the Hindsight server.