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
+193 -66
View File
@@ -6,8 +6,17 @@ on:
- 'v*'
jobs:
build-python-package:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: hindsight-all
path: hindsight
- name: hindsight-api
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
steps:
- uses: actions/checkout@v4
@@ -22,15 +31,46 @@ jobs:
with:
python-version-file: ".python-version"
- name: Build hindsight package
working-directory: ./hindsight
- name: Build ${{ matrix.name }} package
working-directory: ./${{ matrix.path }}
run: uv build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: python-hindsight-dist
path: hindsight/dist/*
name: python-${{ matrix.name }}-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
build-rust-cli:
@@ -101,7 +141,14 @@ jobs:
packages: write
strategy:
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:
- uses: actions/checkout@v4
@@ -117,6 +164,9 @@ jobs:
docker-images: true
swap-storage: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -135,32 +185,21 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/hindsight-${{ matrix.component }}
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
tags: |
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}},value=${{ steps.get_version.outputs.VERSION }}
type=raw,value=latest
- name: Build and push Docker image (api)
if: matrix.component == 'api'
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/api.Dockerfile
push: true
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
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -192,9 +231,63 @@ jobs:
path: helm-packages/*.tgz
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:
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:
contents: write
@@ -205,11 +298,29 @@ jobs:
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Download Python package
- name: Download hindsight-all
uses: actions/download-artifact@v4
with:
name: python-hindsight-dist
path: ./artifacts/python-hindsight-dist
name: python-hindsight-all-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)
uses: actions/download-artifact@v4
@@ -238,8 +349,12 @@ jobs:
- name: Prepare release assets
run: |
mkdir -p release-assets
# Python package
cp artifacts/python-hindsight-dist/* release-assets/
# Python packages
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
cp artifacts/rust-cli-hindsight-linux-amd64/hindsight-linux-amd64 release-assets/
cp artifacts/rust-cli-hindsight-darwin-amd64/hindsight-darwin-amd64 release-assets/
@@ -250,37 +365,61 @@ jobs:
- name: Generate release notes
id: release_notes
run: |
cat << EOF > release-notes.md
cat << 'EOF' > release-notes.md
# 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
### Python Package
- \`hindsight-${{ steps.get_version.outputs.VERSION }}-py3-none-any.whl\`
- \`hindsight-${{ steps.get_version.outputs.VERSION }}.tar.gz\`
### Docker Images
- `ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}` - **Standalone all-in-one** (recommended)
- `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
- \`hindsight-linux-amd64\` - Linux x86_64
- \`hindsight-darwin-amd64\` - macOS Intel
- \`hindsight-darwin-arm64\` - macOS Apple Silicon
- `hindsight-linux-amd64` - Linux x86_64
- `hindsight-darwin-amd64` - macOS Intel
- `hindsight-darwin-arm64` - macOS Apple Silicon
### Helm Chart
- \`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 }}\`
- `hindsight-${{ steps.get_version.outputs.VERSION }}.tgz`
## 🚀 Installation
### Python Package
\`\`\`bash
pip install hindsight==${{ steps.get_version.outputs.VERSION }}
\`\`\`
### Python
```bash
# 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
\`\`\`bash
```bash
# macOS (Apple Silicon)
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-darwin-arm64 -o 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
chmod +x hindsight
sudo mv hindsight /usr/local/bin/
\`\`\`
```
### Helm Chart
\`\`\`bash
helm install hindsight hindsight-${{ steps.get_version.outputs.VERSION }}.tgz
\`\`\`
### 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
\`\`\`
### Helm (Kubernetes)
```bash
helm install hindsight oci://ghcr.io/${{ github.repository_owner }}/charts/hindsight --version ${{ steps.get_version.outputs.VERSION }}
```
EOF
cat release-notes.md
@@ -333,9 +459,10 @@ jobs:
echo "# Release v${{ steps.get_version.outputs.VERSION }} Published Successfully" >> $GITHUB_STEP_SUMMARY
echo "" >> $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 "- ✅ 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 "" >> $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
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.
+67 -30
View File
@@ -2,59 +2,96 @@
**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?
- **Temporal queries** — "What did Alice do last spring?" requires more than vector search
- **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
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.
## 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
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
import os
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)
# Retain memories
client.retain(bank_id="my-agent", content="Alice works at Google")
client.retain(bank_id="my-agent", content="Bob prefers Python over JavaScript")
# 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")
client.retain(bank_id="my-user", content="User prefers functional programming")
response = client.reflect(bank_id="my-user", query="What coding style should I use?")
print(response.text)
```
## 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
- [Python Client](./hindsight-docs/docs/sdks/python.md) — Full API reference
- [API Reference](./hindsight-docs/docs/api-reference/index.md) — REST API endpoints
- [Personality](./hindsight-docs/docs/developer/personality.md) — Big Five traits and opinion formation
- [Architecture](https://vectorize-io.github.io/hindsight/developer/architecture) — How ingestion, storage, and retrieval work
- [Python Client](https://vectorize-io.github.io/hindsight/sdks/python) — Full API reference
- [API Reference](https://vectorize-io.github.io/hindsight/api-reference) — REST API endpoints
- [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
+11
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
-59
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).
-33
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"]
-24
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"
-65
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"]
-42
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:
+199 -29
View File
@@ -1,6 +1,25 @@
# Standalone All-in-One Hindsight Image
# API with embedded pg0 + Control Plane
FROM python:3.11-slim AS api-base
# Hindsight Docker Image
# Supports building API-only, Control Plane-only, or both
#
# 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
@@ -21,12 +40,18 @@ WORKDIR /app/api
# Sync dependencies (will create lock file if needed)
RUN uv sync
# Copy source code
# Copy source code and alembic migrations
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
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping SDK build" && exit 0; fi
WORKDIR /app/sdk
COPY hindsight-clients/typescript/package*.json ./
@@ -35,9 +60,14 @@ RUN npm ci
COPY hindsight-clients/typescript/ ./
RUN npm run build
# Build Control Plane
# =============================================================================
# Stage: Control Plane 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
# Copy built SDK
@@ -59,8 +89,120 @@ RUN npm run build
# Create public directory if it doesn't exist
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
@@ -70,6 +212,7 @@ RUN apt-get update && apt-get install -y \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& 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 - \
&& apt-get install -y nodejs \
@@ -80,22 +223,16 @@ RUN apt-get update && apt-get install -y \
RUN useradd -m -s /bin/bash hindsight
# 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 --from=sdk-builder /app/sdk /app/sdk
# Copy Control Plane
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/package*.json ./
RUN npm ci --omit=dev
# 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/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
COPY --from=cp-builder /app/next.config.ts ./next.config.ts
WORKDIR /app
@@ -109,24 +246,57 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
# Switch to non-root user
USER hindsight
# Install pg0
RUN curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash
# Set PATH for hindsight user
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
# Start pg0 once to verify it works and pre-download PostgreSQL libraries
RUN pg0 --help && \
pg0 start --wait && \
pg0 stop
# 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
# Expose ports
EXPOSE 8888 3000
# 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 9999
# Environment variables
ENV HINDSIGHT_API_HOST=0.0.0.0
ENV HINDSIGHT_API_PORT=8888
ENV HINDSIGHT_API_LOG_LEVEL=info
ENV NODE_ENV=production
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"]
# =============================================================================
# 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
+7 -9
View File
@@ -1,26 +1,24 @@
services:
hindsight:
image: hindsight
build:
context: ../..
dockerfile: docker/standalone/Dockerfile
platform: linux/amd64
env_file:
- ../../.env
ports:
- "3000:3000"
- "9999:9999"
- "8888:8888"
environment:
# Pass through all HINDSIGHT_* environment variables from host
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_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-}
# These override env_file values only when set in host shell
# Default values are applied only when not set in env_file or host
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 can be set if you want to use an external database
# If not set, embedded pg0 will be used automatically
# Add any other HINDSIGHT_* vars you need here
volumes:
- hindsight_data:/app/data
- hindsight_data:/home/hindsight/.pg0
restart: unless-stopped
volumes:
+59 -20
View File
@@ -4,36 +4,75 @@ set -e
echo "🚀 Starting Hindsight..."
echo ""
# Start API (with embedded pg0)
echo "⚡ Starting Hindsight API (with embedded database)..."
cd /app/api
python -m hindsight_api.web.server &
API_PID=$!
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
# Wait for API to be ready
echo "⏳ Waiting for API..."
for i in {1..30}; do
if curl -sf http://localhost:8888/health &>/dev/null || curl -sf http://localhost:8888/docs &>/dev/null; then
echo "✅ API is ready"
break
# 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
sleep 1
done
fi
# Start Control Plane
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
node .next/standalone/server.js &
CP_PID=$!
# Track PIDs for wait
PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
python -m hindsight_api.web.server 2>&1 | sed -u 's/^/[api] /' &
API_PID=$!
PIDS+=($API_PID)
# Wait for API to be ready
echo "⏳ Waiting for API..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null; then
echo "✅ API is ready"
break
fi
sleep 1
done
else
echo "⏭️ API disabled (HINDSIGHT_ENABLE_API=false)"
fi
# Start Control Plane if enabled
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
PORT=9999 node server.js 2>&1 | grep -v -E "^[[:space:]]*(▲|✓|-|$)" | sed -u 's/^/[control-plane] /' &
CP_PID=$!
PIDS+=($CP_PID)
else
echo "⏭️ Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
fi
# Print status
echo ""
echo "✅ Hindsight is running!"
echo ""
echo "📍 Access:"
echo " Control Plane: http://localhost:3000"
echo " API: http://localhost:8888"
if [ "$ENABLE_CP" = "true" ]; then
echo " Control Plane: http://localhost:9999"
fi
if [ "$ENABLE_API" = "true" ]; then
echo " API: http://localhost:8888"
fi
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 -n
+18
View File
@@ -797,6 +797,24 @@ def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_mem
def _register_routes(app: FastAPI):
"""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(
"/metrics",
summary="Prometheus metrics endpoint",
+9 -1
View File
@@ -2,11 +2,19 @@
import json
import logging
import os
from fastmcp import FastMCP
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__)
@@ -10,13 +10,23 @@ import logging
logger = logging.getLogger(__name__)
class CrossEncoderReranker(ABC):
class CrossEncoderModel(ABC):
"""
Abstract base class for cross-encoder reranking.
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
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
@@ -31,12 +41,11 @@ class CrossEncoderReranker(ABC):
pass
class SentenceTransformersCrossEncoder(CrossEncoderReranker):
class SentenceTransformersCrossEncoder(CrossEncoderModel):
"""
Cross-encoder implementation using SentenceTransformers.
Uses lazy import so sentence-transformers is not required if another
reranking backend is used.
Call load() during initialization to load the model and avoid cold starts.
Default model is cross-encoder/ms-marco-MiniLM-L-6-v2:
- Fast inference (~80ms for 100 pairs on CPU)
@@ -46,13 +55,19 @@ class SentenceTransformersCrossEncoder(CrossEncoderReranker):
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:
model_name: Name of the CrossEncoder model to use.
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
"""
self.model_name = model_name
self._model = None
def load(self) -> None:
"""Load the cross-encoder model."""
if self._model is not None:
return
try:
from sentence_transformers import CrossEncoder
@@ -76,5 +91,7 @@ class SentenceTransformersCrossEncoder(CrossEncoderReranker):
Returns:
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)
@@ -24,6 +24,16 @@ class Embeddings(ABC):
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
def encode(self, texts: List[str]) -> List[List[float]]:
"""
@@ -42,8 +52,7 @@ class SentenceTransformersEmbeddings(Embeddings):
"""
Embeddings implementation using SentenceTransformers.
Uses lazy import so sentence-transformers is not required if another
embedding backend is used.
Call load() during initialization to load the model and avoid cold starts.
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
embeddings matching the database schema.
@@ -60,32 +69,33 @@ class SentenceTransformersEmbeddings(Embeddings):
"""
self.model_name = model_name
self._model = None
self._load_model()
def _load_model(self):
"""Lazy load and validate the SentenceTransformer model."""
if self._model is None:
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for SentenceTransformersEmbeddings. "
"Install it with: pip install sentence-transformers"
)
def load(self) -> None:
"""Load the embedding model."""
if self._model is not None:
return
logger.info(f"Loading embedding model: {self.model_name}...")
self._model = SentenceTransformer(self.model_name)
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for SentenceTransformersEmbeddings. "
"Install it with: pip install sentence-transformers"
)
# Validate dimension matches database schema
model_dim = self._model.get_sentence_embedding_dimension()
if model_dim != EMBEDDING_DIMENSION:
raise ValueError(
f"Model {self.model_name} produces {model_dim}-dimensional embeddings, "
f"but database schema requires {EMBEDDING_DIMENSION} dimensions. "
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
)
logger.info(f"Loading embedding model: {self.model_name}...")
self._model = SentenceTransformer(self.model_name)
logger.info(f"Model loaded (embedding dim: {model_dim})")
# Validate dimension matches database schema
model_dim = self._model.get_sentence_embedding_dimension()
if model_dim != EMBEDDING_DIMENSION:
raise ValueError(
f"Model {self.model_name} produces {model_dim}-dimensional embeddings, "
f"but database schema requires {EMBEDDING_DIMENSION} dimensions. "
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
)
logger.info(f"Model loaded (embedding dim: {model_dim})")
def encode(self, texts: List[str]) -> List[List[float]]:
"""
@@ -97,5 +107,7 @@ class SentenceTransformersEmbeddings(Embeddings):
Returns:
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)
return [emb.tolist() for emb in embeddings]
@@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict
import asyncpg
import asyncio
from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .cross_encoder import CrossEncoderReranker as CrossEncoderModel
from .cross_encoder import CrossEncoderModel
import time
import numpy as np
import uuid
@@ -362,15 +362,53 @@ class MemoryEngine:
logger.error(f"Failed to mark operation as failed {operation_id}: {e}")
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:
return
# Start pg0 embedded PostgreSQL if configured
if self._use_pg0:
self._pg0 = EmbeddedPostgres()
self.db_url = await self._pg0.ensure_running()
logger.info(f"Connecting to PostGre instance at {self.db_url}")
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:
self._pg0 = EmbeddedPostgres()
self.db_url = await self._pg0.ensure_running()
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
# For read-heavy workloads with many parallel think/search operations,
@@ -414,6 +452,24 @@ class MemoryEngine:
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):
"""Close the connection pool and shutdown background workers."""
logger.info("close() started")
@@ -503,8 +559,15 @@ class MemoryEngine:
if not texts:
return []
time_lower = event_date - timedelta(hours=time_window_hours)
time_upper = event_date + timedelta(hours=time_window_hours)
# Handle edge cases where event_date is at datetime boundaries
try:
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)
except OverflowError:
time_upper = datetime.max
# Fetch ALL existing facts in time window ONCE (much faster than N queries)
import time as time_mod
@@ -46,6 +46,16 @@ class QueryAnalyzer(ABC):
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
def analyze(
self, query: str, reference_date: Optional[datetime] = None
@@ -94,21 +104,29 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
self._model = None
self._tokenizer = None
def _load_model(self):
"""Lazy load the T5 model for temporal extraction."""
if self._model is None:
try:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
except ImportError:
raise ImportError(
"transformers is required for TransformerQueryAnalyzer. "
"Install it with: pip install transformers"
)
def load(self) -> None:
"""Load the T5 model for temporal extraction."""
if self._model is not None:
return
self._tokenizer = AutoTokenizer.from_pretrained(self.model_name)
self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
self._model.to(self.device)
self._model.eval()
try:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
except ImportError:
raise ImportError(
"transformers is required for TransformerQueryAnalyzer. "
"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._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name)
self._model.to(self.device)
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(
self, query: str, reference_date: Optional[datetime] = None
@@ -5,11 +5,107 @@ Link creation utilities for temporal, semantic, and entity links.
import time
import logging
from typing import List
from datetime import timedelta
from datetime import timedelta, datetime, timezone
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'):
"""Helper to log to buffer if available, otherwise use logger."""
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")
# Fetch ALL potential temporal neighbors in ONE query (much faster!)
# Get time range across all units
all_dates = list(new_units.values())
min_date = min(all_dates) - timedelta(hours=time_window_hours)
max_date = max(all_dates) + timedelta(hours=time_window_hours)
# Get time range across all units with overflow protection
min_date, max_date = compute_temporal_query_bounds(new_units, time_window_hours)
fetch_neighbors_start = time_mod.time()
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)
link_gen_start = time_mod.time()
links = []
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))
links = compute_temporal_links(new_units, all_candidates, time_window_hours)
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links:
@@ -23,9 +23,11 @@ class CrossEncoderReranker:
Args:
cross_encoder: CrossEncoderReranker instance. If None, uses default
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
(loaded lazily for faster startup)
"""
if cross_encoder is None:
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
# Model is loaded lazily - call ensure_loaded() during initialize()
cross_encoder = SentenceTransformersCrossEncoder()
self.cross_encoder = cross_encoder
+143 -145
View File
@@ -3,10 +3,10 @@ import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
from pathlib import Path
from typing import Optional
@@ -14,8 +14,7 @@ import httpx
logger = logging.getLogger(__name__)
DEFAULT_DATA_DIR = Path(os.environ.get("HINDSIGHT_API_PG0_DATA_DIR", Path.home() / ".hindsight" / "pg_data"))
DEFAULT_INSTALL_DIR = Path.home() / ".hindsight" / "bin"
# pg0 configuration
BINARY_NAME = "pg0"
DEFAULT_PORT = 5555
DEFAULT_USERNAME = "hindsight"
@@ -65,9 +64,7 @@ def get_download_url(
version: str = "latest",
repo: str = "vectorize-io/pg0",
) -> str:
"""
"""
# Check for direct URL override
"""Get the download URL for pg0 binary."""
binary_name = get_platform_binary_name()
if version == "latest":
@@ -76,17 +73,32 @@ def get_download_url(
return f"https://github.com/{repo}/releases/download/{version}/{binary_name}"
def _find_pg0_binary() -> Optional[Path]:
"""Find pg0 binary in PATH or default install location."""
# First check PATH
pg0_in_path = shutil.which("pg0")
if pg0_in_path:
return Path(pg0_in_path)
# Fall back to default install location
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists() and os.access(default_path, os.X_OK):
return default_path
return None
class EmbeddedPostgres:
"""
Manages an embedded PostgreSQL server instance.
Manages an embedded PostgreSQL server instance using pg0.
This class handles:
- Downloading and installing the embedded-postgres CLI
- Finding or downloading the pg0 CLI
- Starting/stopping the PostgreSQL server
- Getting the connection URI
Example:
pg = EmbeddedPostgres(data_dir="~/.myapp/data")
pg = EmbeddedPostgres()
await pg.ensure_installed()
await pg.start()
uri = await pg.get_uri()
@@ -96,8 +108,6 @@ class EmbeddedPostgres:
def __init__(
self,
data_dir: Optional[Path] = None,
install_dir: Optional[Path] = None,
version: str = "latest",
port: int = DEFAULT_PORT,
username: str = DEFAULT_USERNAME,
@@ -109,17 +119,13 @@ class EmbeddedPostgres:
Initialize the embedded PostgreSQL manager.
Args:
data_dir: Directory to store PostgreSQL data. Defaults to ~/.hindsight/pg_data
install_dir: Directory to install the CLI binary. Defaults to ~/.hindsight/bin
version: Version of embedded-postgres to use. Defaults to "latest"
version: Version of pg0 to download if not found. Defaults to "latest"
port: Port to listen on. Defaults to 5555
username: Username for the database. Defaults to "hindsight"
password: Password for the database. Defaults to "hindsight"
database: Database name to create. Defaults to "hindsight"
name: Instance name for pg0. Defaults to "hindsight"
"""
self.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.port = port
self.username = username
@@ -127,35 +133,42 @@ class EmbeddedPostgres:
self.database = database
self.name = name
# Binary path
binary_name = "pg0.exe" if platform.system() == "Windows" else "pg0"
self.binary_path = self.install_dir / binary_name
# Will be set when binary is found/installed
self._binary_path: Optional[Path] = _find_pg0_binary()
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:
"""Check if the embedded-postgres CLI is installed."""
return self.binary_path.exists() and os.access(self.binary_path, os.X_OK)
"""Check if pg0 is available (in PATH or installed)."""
self._binary_path = _find_pg0_binary()
return self._binary_path is not None
async def ensure_installed(self) -> None:
"""
Ensure 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():
logger.info(f"pg0 already installed at {self.binary_path}")
logger.debug(f"pg0 found at {self._binary_path}")
return
logger.info("Installing pg0 CLI...")
logger.info("pg0 not found, downloading...")
# Log platform information
binary_name = get_platform_binary_name()
logger.info(f"Detected platform: system={platform.system()}, machine={platform.machine()}")
logger.info(f"Will download binary: {binary_name}")
# Create install directory
self.install_dir.mkdir(parents=True, exist_ok=True)
# Install to default location
install_dir = Path.home() / ".hindsight" / "bin"
install_dir.mkdir(parents=True, exist_ok=True)
install_path = install_dir / "pg0"
# Download the binary
download_url = get_download_url(self.version)
@@ -167,85 +180,115 @@ class EmbeddedPostgres:
response.raise_for_status()
# Write binary to disk
with open(self.binary_path, "wb") as f:
with open(install_path, "wb") as f:
f.write(response.content)
# Make executable on Unix
if platform.system() != "Windows":
st = os.stat(self.binary_path)
os.chmod(self.binary_path, st.st_mode | stat.S_IEXEC)
st = os.stat(install_path)
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:
raise RuntimeError(f"Failed to download pg0: {e}") from e
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]
return subprocess.run(
cmd,
capture_output=capture_output,
text=True,
)
def run_sync():
try:
result = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return 1, "", "Command timed out"
async def _run_command_async(self, *args: str) -> tuple[int, str, str]:
"""Run an embedded-postgres command asynchronously."""
cmd = [str(self.binary_path), *args]
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, run_sync)
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
def _extract_uri_from_output(self, output: str) -> Optional[str]:
"""Extract the PostgreSQL URI from pg0 start output."""
match = re.search(r"Connection URI:\s*(postgresql://[^\s]+)", output)
if match:
return match.group(1)
return None
stdout, stderr = await process.communicate()
return process.returncode, stdout.decode(), stderr.decode()
async def start(self) -> str:
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> 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:
The connection URI for the started server.
Raises:
RuntimeError: If the server fails to start.
RuntimeError: If the server fails to start after all retries.
"""
if not self.is_installed():
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
# Create data directory
self.data_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
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(
"start",
"--name", self.name,
"--port", str(self.port),
"--username", self.username,
"--password", self.password,
"--database", self.database,
timeout=300,
)
returncode, stdout, stderr = await self._run_command_async(
"start",
"--name", self.name,
"--port", str(self.port),
"--username", self.username,
"--password", self.password,
"--database", self.database,
"--data-dir", self.data_dir.as_posix()
)
# Try to extract URI from output
uri = self._extract_uri_from_output(stdout)
if uri:
logger.info(f"PostgreSQL started on port {self.port}")
return uri
if returncode != 0:
raise RuntimeError(f"Failed to start PostgreSQL: {stderr}")
# 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
logger.info("Embedded PostgreSQL started")
# Start failed, log and retry
last_error = stderr or f"pg0 start returned exit code {returncode}"
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.info(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.warning(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
# Get and return the URI
return await self.get_uri()
# 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:
"""
Stop the PostgreSQL server.
Raises:
RuntimeError: If the server fails to stop.
"""
"""Stop the PostgreSQL server."""
if not self.is_installed():
return
@@ -254,7 +297,6 @@ class EmbeddedPostgres:
returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name)
if returncode != 0:
# Don't raise if server wasn't running
if "not running" in stderr.lower():
return
raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}")
@@ -262,20 +304,13 @@ class EmbeddedPostgres:
logger.info("Embedded PostgreSQL stopped")
async def _get_info(self) -> dict:
"""
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.
"""
"""Get info from pg0 using the `info -o json` command."""
if not self.is_installed():
raise RuntimeError("pg0 is not installed.")
returncode, stdout, stderr = await self._run_command_async(
"info", "--name", self.name, "-o", "json")
"info", "--name", self.name, "-o", "json"
)
if returncode != 0:
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}")
async def get_uri(self) -> str:
"""
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.
"""
"""Get the connection URI for the PostgreSQL server."""
info = await self._get_info()
uri = info.get("uri")
if not uri:
@@ -302,12 +329,7 @@ class EmbeddedPostgres:
return uri
async def status(self) -> dict:
"""
Get the status of the PostgreSQL server.
Returns:
Dictionary with status information including 'running' boolean and 'uri'.
"""
"""Get the status of the PostgreSQL server."""
if not self.is_installed():
return {"installed": False, "running": False}
@@ -317,16 +339,9 @@ class EmbeddedPostgres:
"installed": True,
"running": info.get("running", False),
"uri": info.get("uri"),
"data_dir": str(self.data_dir),
"binary_path": str(self.binary_path),
}
except RuntimeError:
return {
"installed": True,
"running": False,
"data_dir": str(self.data_dir),
"binary_path": str(self.binary_path),
}
return {"installed": True, "running": False}
async def is_running(self) -> bool:
"""Check if the PostgreSQL server is currently running."""
@@ -355,59 +370,42 @@ class EmbeddedPostgres:
return await self.start()
def uninstall(self) -> None:
"""Remove the embedded-postgres binary."""
if self.binary_path.exists():
self.binary_path.unlink()
logger.info(f"Removed {self.binary_path}")
"""Remove the pg0 binary (only if we installed it)."""
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists():
default_path.unlink()
logger.info(f"Removed {default_path}")
def clear_data(self) -> None:
"""Remove all PostgreSQL data (destructive!)."""
if self.data_dir.exists():
shutil.rmtree(self.data_dir)
logger.info(f"Removed data directory {self.data_dir}")
result = self._run_command("drop", "--name", self.name, "--force")
if result.returncode == 0:
logger.info(f"Dropped pg0 instance {self.name}")
else:
logger.warning(f"Failed to drop pg0 instance {self.name}: {result.stderr}")
# Convenience functions for simple usage
# Convenience functions
_default_instance: Optional[EmbeddedPostgres] = None
def get_embedded_postgres(
data_dir: Optional[Path] = None,
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
"""
def get_embedded_postgres() -> EmbeddedPostgres:
"""Get or create the default EmbeddedPostgres instance."""
global _default_instance
if _default_instance is None or data_dir or install_dir:
_default_instance = EmbeddedPostgres(
data_dir=data_dir,
install_dir=install_dir,
)
if _default_instance is None:
_default_instance = EmbeddedPostgres()
return _default_instance
async def start_embedded_postgres(
data_dir: Optional[Path] = None,
) -> str:
async def start_embedded_postgres() -> str:
"""
Quick start function for embedded PostgreSQL.
Downloads, installs, and starts PostgreSQL in one call.
Args:
data_dir: Directory to store PostgreSQL data
Returns:
Connection URI string
@@ -415,7 +413,7 @@ async def start_embedded_postgres(
db_url = await start_embedded_postgres()
conn = await asyncpg.connect(db_url)
"""
pg = get_embedded_postgres(data_dir=data_dir)
pg = get_embedded_postgres()
return await pg.ensure_running()
@@ -424,4 +422,4 @@ async def stop_embedded_postgres() -> None:
global _default_instance
if _default_instance:
await _default_instance.stop()
await _default_instance.stop()
+21 -3
View File
@@ -79,7 +79,10 @@ app = create_app(
if __name__ == "__main__":
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
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("--reload", action="store_true", help="Enable auto-reload on code changes")
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
parser.add_argument("--log-level", default="info", choices=["critical", "error", "warning", "info", "debug", "trace"],
help="Log level (default: info)")
parser.add_argument("--log-level", default=env_log_level, choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {env_log_level}, from HINDSIGHT_API_LOG_LEVEL)")
parser.add_argument("--access-log", action="store_true", help="Enable access log")
parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log")
parser.add_argument("--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers")
@@ -99,6 +102,21 @@ if __name__ == "__main__":
args = parser.parse_args()
# Configure Python logging based on log level
log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
}
logging.basicConfig(
level=log_level_map.get(args.log_level, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
logging.info(f"Starting Hindsight API on {args.host}:{args.port}")
app_ref = "hindsight_api.web.server:app"
# Prepare uvicorn config
+256
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
+111
View File
@@ -12,7 +12,9 @@
"@hindsight/client": "file:../hindsight-clients/typescript",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@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-slot": "^1.2.4",
"@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": {
"version": "1.1.15",
"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": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+2
View File
@@ -16,7 +16,9 @@
"@hindsight/client": "file:../hindsight-clients/typescript",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@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-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.17",
@@ -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 }
);
}
}
@@ -4,6 +4,7 @@ import * as React from 'react';
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBank } from '@/lib/bank-context';
import { client } from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -18,19 +19,105 @@ import {
PopoverContent,
PopoverTrigger,
} 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';
function BankSelectorInner() {
const router = useRouter();
const searchParams = useSearchParams();
const { currentBank, setCurrentBank, banks } = useBank();
const { currentBank, setCurrentBank, banks, loadBanks } = useBank();
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(() => {
return [...banks].sort((a, b) => a.localeCompare(b));
}, [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 (
<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">
@@ -81,6 +168,163 @@ function BankSelectorInner() {
</Command>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-9 border-2 border-primary hover:bg-accent gap-1.5"
onClick={() => setCreateDialogOpen(true)}
title="Create new memory bank"
>
<Plus className="h-4 w-4" />
<span>New Bank</span>
</Button>
{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>
);
@@ -7,8 +7,8 @@ import cytoscape from 'cytoscape';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Copy, Check, X, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Copy, Check, X, Calendar, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileText, Layers } from 'lucide-react';
import { MemoryDetailPanel } from './memory-detail-panel';
type FactType = 'world' | 'bank' | 'opinion';
type ViewMode = 'graph' | 'table' | 'timeline';
@@ -31,6 +31,8 @@ export function DataView({ factType }: DataViewProps) {
const [selectedChunk, setSelectedChunk] = useState<any>(null);
const [loadingChunk, setLoadingChunk] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [selectedGraphNode, setSelectedGraphNode] = useState<any>(null);
const [selectedTableMemory, setSelectedTableMemory] = useState<any>(null);
const itemsPerPage = 100;
const cyRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -195,6 +197,23 @@ export function DataView({ factType }: DataViewProps) {
] as any,
layout: layouts[layout] || layouts.circle,
});
// Add click handler for nodes
cyRef.current.on('tap', 'node', (evt: any) => {
const nodeId = evt.target.id();
// Find the corresponding table row data
const nodeData = data.table_rows?.find((row: any) => row.id === nodeId);
if (nodeData) {
setSelectedGraphNode(nodeData);
}
});
// Click on background to deselect
cyRef.current.on('tap', (evt: any) => {
if (evt.target === cyRef.current) {
setSelectedGraphNode(null);
}
});
};
useEffect(() => {
@@ -265,70 +284,95 @@ export function DataView({ factType }: DataViewProps) {
</div>
{viewMode === 'graph' && (
<div className="relative">
<div className="p-4 bg-card border-b-2 border-primary flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Limit nodes:</label>
<Input
type="number"
value={nodeLimit}
onChange={(e) => setNodeLimit(parseInt(e.target.value))}
min="10"
max="1000"
step="10"
className="w-20"
<div className="flex gap-4">
<div className={`relative transition-all ${selectedGraphNode ? 'w-2/3' : 'w-full'}`}>
<div className="p-4 bg-card border-b-2 border-primary flex gap-4 items-center flex-wrap">
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Limit nodes:</label>
<Input
type="number"
value={nodeLimit}
onChange={(e) => setNodeLimit(parseInt(e.target.value))}
min="10"
max="1000"
step="10"
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Layout:</label>
<Select value={layout} onValueChange={setLayout}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="circle">Circle (fast)</SelectItem>
<SelectItem value="grid">Grid (fast)</SelectItem>
<SelectItem value="cose">Force-directed (slow)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground ml-auto">
Click on a node to view details
</div>
</div>
<div ref={containerRef} className="w-full h-[800px] bg-background" />
<div className="absolute top-20 left-5 bg-card p-4 border-2 border-primary rounded-lg shadow-lg max-w-[250px]">
<h3 className="font-bold mb-2 border-b-2 border-primary pb-1 text-card-foreground">Legend</h3>
<h4 className="font-bold mt-2 mb-1 text-sm text-card-foreground">Link Types:</h4>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-cyan-500 border-t border-dashed border-cyan-500" />
<span className="text-sm"><strong>Temporal</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-pink-500" />
<span className="text-sm"><strong>Semantic</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-yellow-500" />
<span className="text-sm"><strong>Entity</strong></span>
</div>
<h4 className="font-bold mt-2 mb-1 text-sm">Nodes:</h4>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-gray-300 border border-gray-500 rounded" />
<span className="text-sm">No entities</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-300 border border-gray-500 rounded" />
<span className="text-sm">1 entity</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-500 border border-gray-500 rounded" />
<span className="text-sm">2+ entities</span>
</div>
</div>
</div>
{/* 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 className="flex items-center gap-2">
<label className="font-semibold text-card-foreground">Layout:</label>
<Select value={layout} onValueChange={setLayout}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="circle">Circle (fast)</SelectItem>
<SelectItem value="grid">Grid (fast)</SelectItem>
<SelectItem value="cose">Force-directed (slow)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div ref={containerRef} className="w-full h-[800px] bg-background" />
<div className="absolute top-20 left-5 bg-card p-4 border-2 border-primary rounded-lg shadow-lg max-w-[250px]">
<h3 className="font-bold mb-2 border-b-2 border-primary pb-1 text-card-foreground">Legend</h3>
<h4 className="font-bold mt-2 mb-1 text-sm text-card-foreground">Link Types:</h4>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-cyan-500 border-t border-dashed border-cyan-500" />
<span className="text-sm"><strong>Temporal</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-pink-500" />
<span className="text-sm"><strong>Semantic</strong></span>
</div>
<div className="flex items-center my-2">
<div className="w-8 h-0.5 mr-2.5 bg-yellow-500" />
<span className="text-sm"><strong>Entity</strong></span>
</div>
<h4 className="font-bold mt-2 mb-1 text-sm">Nodes:</h4>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-gray-300 border border-gray-500 rounded" />
<span className="text-sm">No entities</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-300 border border-gray-500 rounded" />
<span className="text-sm">1 entity</span>
</div>
<div className="flex items-center my-2">
<div className="w-5 h-5 mr-2.5 bg-blue-500 border border-gray-500 rounded" />
<span className="text-sm">2+ entities</span>
</div>
</div>
)}
</div>
)}
{viewMode === 'table' && (
<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">
<Input
type="text"
@@ -338,173 +382,211 @@ export function DataView({ factType }: DataViewProps) {
className="max-w-2xl"
/>
</div>
<div className="overflow-x-auto 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 ? (
(() => {
const filteredRows = data.table_rows.filter((row: any) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query)
);
});
<div className="px-5 pb-5">
{data.table_rows && data.table_rows.length > 0 ? (
(() => {
const filteredRows = data.table_rows.filter((row: any) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query)
);
});
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, endIndex);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, endIndex);
return paginatedRows.map((row: any, idx: number) => {
// Format temporal range
let occurredDisplay = 'N/A';
if (row.occurred_start && row.occurred_end) {
const start = new Date(row.occurred_start).toLocaleString();
const end = new Date(row.occurred_end).toLocaleString();
occurredDisplay = start === end ? start : `${start} - ${end}`;
} else if (row.date) {
// Fallback to old date field
occurredDisplay = row.date;
}
return (
<>
<div className="grid gap-3">
{paginatedRows.map((row: any, idx: number) => {
const occurredDisplay = row.occurred_start
? new Date(row.occurred_start).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const mentionedDisplay = row.mentioned_at
? new Date(row.mentioned_at).toLocaleString()
: 'N/A';
return (
<div
key={row.id || idx}
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'
}`}
>
<div className="flex items-start gap-4">
{/* Main content */}
<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)}...
</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>
return (
<TableRow
key={idx}
className={selectedDocument?.id === row.document_id ? 'bg-accent' : ''}
>
<TableCell>
<div className="flex items-center gap-2">
<span title={row.id} className="text-muted-foreground">
{row.id.substring(0, 8)}...
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
copyToClipboard(row.id);
}}
>
{copiedId === row.id ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
{/* Action buttons */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{row.document_id && (
<Button
onClick={(e) => {
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);
}}
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
title="Copy ID"
>
{copiedId === row.id ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</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>
</TableCell>
</TableRow>
);
});
})()
) : (
<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>
);
})}
</div>
{/* Pagination Controls */}
{data.table_rows && data.table_rows.length > 0 && (() => {
const filteredRows = data.table_rows.filter((row: any) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
row.text?.toLowerCase().includes(query) ||
row.context?.toLowerCase().includes(query)
);
});
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">
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredRows.length)} of {filteredRows.length} results
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<div className="flex items-center gap-2 px-3">
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
Showing {startIndex + 1} to {Math.min(endIndex, filteredRows.length)} of {filteredRows.length}
</div>
<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
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm px-3">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
>
<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>
</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>
{/* 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 */}
{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="flex justify-between items-start mb-4">
<div>
@@ -571,7 +653,7 @@ export function DataView({ factType }: DataViewProps) {
{/* Chunk Detail Panel */}
{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="flex justify-between items-start mb-4">
<div>
@@ -1000,87 +1082,12 @@ function TimelineView({ data, onViewDocument }: { data: any; onViewDocument: (id
{/* Detail Panel */}
{selectedItem && (
<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">
<div className="flex justify-between items-start mb-3">
<h3 className="text-sm font-semibold text-card-foreground">Details</h3>
<Button
variant="ghost"
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>
<MemoryDetailPanel
memory={selectedItem}
onClose={() => setSelectedItem(null)}
onViewDocument={onViewDocument}
compact
/>
</div>
)}
</div>
@@ -3,11 +3,8 @@
import { useState, useEffect } from 'react';
import { client } from '@/lib/api';
import { useBank } from '@/lib/bank-context';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
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 { X } from 'lucide-react';
@@ -18,16 +15,6 @@ export function DocumentsView() {
const [searchQuery, setSearchQuery] = useState('');
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
const [selectedDocument, setSelectedDocument] = useState<any>(null);
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
useEffect(() => {
if (currentBank) {
@@ -123,94 +66,6 @@ export function DocumentsView() {
return (
<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 */}
{loading ? (
<div className="flex items-center justify-center py-20">
@@ -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>
);
}
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Info } from 'lucide-react';
@@ -149,6 +151,7 @@ export function SearchDebugView() {
trace: data.trace || null,
loading: false,
currentRetrievalFactType: defaultFactType,
currentPhase: 'final',
});
} catch (error) {
console.error('Error running search:', error);
@@ -651,52 +654,43 @@ export function SearchDebugView() {
{/* Phase Controls */}
{pane.trace && (
<div className="p-2.5 bg-card border-b-2 border-primary flex gap-3">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name={`phase-${pane.id}`}
checked={pane.currentPhase === 'retrieval'}
onChange={() => updatePane(pane.id, { currentPhase: 'retrieval' })}
/>
<span className="text-xs font-bold">1. Retrieval</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name={`phase-${pane.id}`}
checked={pane.currentPhase === 'rrf'}
onChange={() => updatePane(pane.id, { currentPhase: 'rrf' })}
/>
<span className="text-xs font-bold">2. RRF Merge</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name={`phase-${pane.id}`}
checked={pane.currentPhase === 'rerank'}
onChange={() => updatePane(pane.id, { currentPhase: 'rerank' })}
/>
<span className="text-xs font-bold">3. Reranking</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name={`phase-${pane.id}`}
checked={pane.currentPhase === 'json'}
onChange={() => updatePane(pane.id, { currentPhase: 'json' })}
/>
<span className="text-xs font-bold">4. Raw JSON</span>
</label>
<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 className="p-2.5 bg-card border-b-2 border-primary">
<RadioGroup
value={pane.currentPhase}
onValueChange={(value) => updatePane(pane.id, { currentPhase: value as Phase })}
className="flex gap-3"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="retrieval" id={`phase-retrieval-${pane.id}`} />
<Label htmlFor={`phase-retrieval-${pane.id}`} className="text-xs font-bold cursor-pointer">
1. Retrieval
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="rrf" id={`phase-rrf-${pane.id}`} />
<Label htmlFor={`phase-rrf-${pane.id}`} className="text-xs font-bold cursor-pointer">
2. RRF Merge
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="rerank" id={`phase-rerank-${pane.id}`} />
<Label htmlFor={`phase-rerank-${pane.id}`} className="text-xs font-bold cursor-pointer">
3. Reranking
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="json" id={`phase-json-${pane.id}`} />
<Label htmlFor={`phase-json-${pane.id}`} className="text-xs font-bold cursor-pointer">
4. Raw JSON
</Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="final" id={`phase-final-${pane.id}`} />
<Label htmlFor={`phase-final-${pane.id}`} className="text-xs font-bold cursor-pointer">
5. Final Results
</Label>
</div>
</RadioGroup>
</div>
)}
@@ -2,142 +2,121 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
const Dialog = DialogPrimitive.Root
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
const DialogTrigger = DialogPrimitive.Trigger
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
const DialogPortal = DialogPrimitive.Portal
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
const DialogClose = DialogPrimitive.Close
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"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
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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 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
)}
{...props}
/>
)
}
>
{children}
<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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
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>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<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(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
const DialogHeader = ({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
function DialogDescription({
const DialogFooter = ({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
}: React.HTMLAttributes<HTMLDivElement>) => (
<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
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -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 }
@@ -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 }
+10
View File
@@ -28,6 +28,16 @@ export class ControlPlaneClient {
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
*/
@@ -913,12 +913,12 @@ class BenchmarkRunner:
# Only clear on first item for shared agent_id
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:
has_data = await self._agent_has_data(item_agent_id)
if has_data:
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 - agent '{item_agent_id}' already has indexed data")
if item_id in existing_item_ids:
console.print(f"\n[bold blue]Item {i}/{len(items)}[/bold blue] (ID: {item_id})")
console.print(f" [yellow]⊘[/yellow] Skipping - already has results in output file")
continue
result = await self.process_single_item(
@@ -981,12 +981,11 @@ class BenchmarkRunner:
item_id = self.dataset.get_item_id(item)
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:
has_data = await self._agent_has_data(item_agent_id)
if has_data:
if item_id in existing_item_ids:
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
# Process the item
@@ -551,7 +551,7 @@ if __name__ == "__main__":
parser.add_argument(
"--fill",
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(
"--question-id",
+99 -83
View File
@@ -30,6 +30,10 @@ Associate retained content with a document:
<TabItem value="python" label="Python">
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Retain with document ID
client.retain(
bank_id="my-bank",
@@ -40,7 +44,7 @@ client.retain(
# Batch retain for a document
client.retain_batch(
bank_id="my-bank",
contents=[
items=[
{"content": "Item 1: Product launch delayed to Q2"},
{"content": "Item 2: New hiring targets announced"},
{"content": "Item 3: Budget approved for ML team"}
@@ -60,24 +64,22 @@ with open("notes.txt") as f:
</TabItem>
<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
await client.retain({
bankId: 'my-bank',
content: 'Alice presented the Q4 roadmap...',
documentId: 'meeting-2024-03-15'
await client.retain('my-bank', 'Alice presented the Q4 roadmap...', {
document_id: 'meeting-2024-03-15'
});
// Batch retain
await client.retainBatch({
bankId: 'my-bank',
contents: [
{ content: 'Item 1: Product launch delayed to Q2' },
{ content: 'Item 2: New hiring targets announced' },
{ content: 'Item 3: Budget approved for ML team' }
],
documentId: 'meeting-2024-03-15'
});
await client.retainBatch('my-bank', [
{ content: 'Item 1: Product launch delayed to Q2' },
{ content: 'Item 2: New hiring targets announced' },
{ content: 'Item 3: Budget approved for ML team' }
], { documentId: 'meeting-2024-03-15' });
```
</TabItem>
@@ -120,19 +122,15 @@ client.retain(
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
```typescript
// Original
await client.retain({
bankId: 'my-bank',
content: 'Project deadline: March 31',
documentId: 'project-plan'
await client.retain('my-bank', 'Project deadline: March 31', {
document_id: 'project-plan'
});
// Update
await client.retain({
bankId: 'my-bank',
content: 'Project deadline: April 15 (extended)',
documentId: 'project-plan'
await client.retain('my-bank', 'Project deadline: April 15 (extended)', {
document_id: 'project-plan'
});
```
@@ -158,16 +156,23 @@ View all documents in a memory bank:
<TabItem value="python" label="Python">
```python
# List all documents
documents = client.list_documents(bank_id="my-bank")
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
for doc in documents:
print(f"{doc['id']}: {doc['memory_count']} memories")
print(f" Created: {doc['created_at']}")
print(f" Updated: {doc['updated_at']}")
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all documents
response = api.list_documents(bank_id="my-bank")
for doc in response.items:
print(f"{doc.id}: {doc.memory_unit_count} memories")
print(f" Created: {doc.created_at}")
# With pagination
documents = client.list_documents(
response = api.list_documents(
bank_id="my-bank",
limit=50,
offset=0
@@ -177,17 +182,21 @@ documents = client.list_documents(
</TabItem>
<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
const documents = await client.listDocuments({
bankId: 'my-bank'
const response = await sdk.listDocuments({
client: apiClient,
path: { bank_id: 'my-bank' }
});
documents.forEach(doc => {
console.log(`${doc.id}: ${doc.memoryCount} memories`);
console.log(` Created: ${doc.createdAt}`);
console.log(` Updated: ${doc.updatedAt}`);
});
for (const doc of response.data.items) {
console.log(`${doc.id}: ${doc.memory_unit_count} memories`);
console.log(` Created: ${doc.created_at}`);
}
```
</TabItem>
@@ -206,52 +215,36 @@ hindsight documents list my-bank --limit 50
## Get Document Details
Retrieve a specific document with its memories:
Retrieve a specific document with its content:
<Tabs>
<TabItem value="python" label="Python">
```python
# Get document
doc = client.get_document(
doc = api.get_document(
bank_id="my-bank",
document_id="meeting-2024-03-15"
)
print(f"Document: {doc['id']}")
print(f"Original text: {doc['original_text'][:200]}...")
print(f"Memories: {doc['memory_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']}")
print(f"Document: {doc.id}")
print(f"Original text: {doc.original_text[:200]}...")
print(f"Memories: {doc.memory_unit_count}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
```typescript
// Get document
const doc = await client.getDocument({
bankId: 'my-bank',
documentId: 'meeting-2024-03-15'
const doc = await sdk.getDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
});
console.log(`Document: ${doc.id}`);
console.log(`Memories: ${doc.memoryCount}`);
// Get with memories
const withMemories = await client.getDocument({
bankId: 'my-bank',
documentId: 'meeting-2024-03-15',
includeMemories: true
});
console.log(`Document: ${doc.data.id}`);
console.log(`Original text: ${doc.data.original_text.substring(0, 200)}...`);
console.log(`Memories: ${doc.data.memory_unit_count}`);
```
</TabItem>
@@ -260,9 +253,6 @@ const withMemories = await client.getDocument({
```bash
# Get document
hindsight documents get my-bank meeting-2024-03-15
# With memories
hindsight documents get my-bank meeting-2024-03-15 --include-memories
```
</TabItem>
@@ -277,31 +267,31 @@ Remove a document and all its memories:
```python
# Delete document (removes all associated memories)
client.delete_document(
api.delete_document(
bank_id="my-bank",
document_id="old-meeting"
)
# Bulk delete
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 value="node" label="Node.js">
```javascript
```typescript
// Delete document
await client.deleteDocument({
bankId: 'my-bank',
documentId: 'old-meeting'
await sdk.deleteDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'old-meeting' }
});
// Bulk delete
for (const docId of ['old-1', 'old-2', 'old-3']) {
await client.deleteDocument({
bankId: 'my-bank',
documentId: docId
await sdk.deleteDocument({
client: apiClient,
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",
"bank_id": "my-bank",
"original_text": "Alice presented the Q4 roadmap...",
"content_hash": "sha256:abc123...",
"memory_count": 12,
"memory_unit_count": 12,
"created_at": "2024-03-15T14:00:00Z",
"updated_at": "2024-03-15T14:00:00Z",
"metadata": {}
"retain_params": {
"context": "team meeting",
"event_date": "2024-03-15"
}
}
```
@@ -339,7 +330,12 @@ hindsight documents delete my-bank old-meeting --confirm
### Meeting Notes
<Tabs>
<TabItem value="python" label="Python">
```python
from datetime import date
# Store meeting notes with date-based IDs
client.retain(
bank_id="team-memory",
@@ -348,10 +344,21 @@ client.retain(
)
```
</TabItem>
</Tabs>
### Documentation
<Tabs>
<TabItem value="python" label="Python">
```python
from pathlib import Path
# Store docs with version tracking
docs_dir = Path("docs")
version = "1.0"
for file in docs_dir.glob("*.md"):
client.retain(
bank_id="docs-memory",
@@ -360,8 +367,14 @@ for file in docs_dir.glob("*.md"):
)
```
</TabItem>
</Tabs>
### Conversation History
<Tabs>
<TabItem value="python" label="Python">
```python
# Store chat history with session IDs
client.retain(
@@ -371,6 +384,9 @@ client.retain(
)
```
</TabItem>
</Tabs>
## Next Steps
- [**Entities**](./entities) — Track people, places, and concepts
+78 -87
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:
<Tabs>
<TabItem value="python" label="Python">
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.retain(
bank_id="my-bank",
content="Alice works at Google in Mountain View. She specializes in TensorFlow."
)
```
</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:**
- **Alice** (person)
- **Google** (organization)
@@ -46,14 +67,22 @@ Get all entities tracked in a memory bank:
<TabItem value="python" label="Python">
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all entities
entities = client.list_entities(bank_id="my-bank")
response = api.list_entities(bank_id="my-bank")
for entity in entities:
print(f"{entity['name']}: {entity['mention_count']} mentions")
for entity in response.items:
print(f"{entity.canonical_name}: {entity.mention_count} mentions")
# List with filters
entities = client.list_entities(
# List with pagination
response = api.list_entities(
bank_id="my-bank",
limit=50,
offset=0
@@ -63,21 +92,26 @@ entities = client.list_entities(
</TabItem>
<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
const entities = await client.listEntities({
bankId: 'my-bank'
const response = await sdk.listEntities({
client: apiClient,
path: { bank_id: 'my-bank' }
});
entities.forEach(e => {
console.log(`${e.name}: ${e.mentionCount} mentions`);
});
for (const entity of response.data.items) {
console.log(`${entity.canonical_name}: ${entity.mention_count} mentions`);
}
// List with filters
const filtered = await client.listEntities({
bankId: 'my-bank',
limit: 50,
offset: 0
// List with pagination
const paginated = await sdk.listEntities({
client: apiClient,
path: { bank_id: 'my-bank' },
query: { limit: 50, offset: 0 }
});
```
@@ -103,58 +137,39 @@ Retrieve detailed information about a specific entity:
<TabItem value="python" label="Python">
```python
# Get entity state (observations + related facts)
entity = client.get_entity(
# Get entity details with observations
entity = api.get_entity(
bank_id="my-bank",
entity_id="entity-uuid"
)
print(f"Entity: {entity['name']}")
print(f"First seen: {entity['first_seen']}")
print(f"Mentions: {entity['mention_count']}")
print(f"Entity: {entity.canonical_name}")
print(f"First seen: {entity.first_seen}")
print(f"Mentions: {entity.mention_count}")
# Observations (synthesized summaries)
for obs in entity['observations']:
print(f" - {obs['text']}")
# 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']}")
for obs in entity.observations:
print(f" - {obs.text}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
// Get entity state
const entity = await client.getEntity({
bankId: 'my-bank',
entityId: 'entity-uuid'
```typescript
// Get entity details
const entity = await sdk.getEntity({
client: apiClient,
path: { bank_id: 'my-bank', entity_id: 'entity-uuid' }
});
console.log(`Entity: ${entity.name}`);
console.log(`First seen: ${entity.firstSeen}`);
console.log(`Mentions: ${entity.mentionCount}`);
console.log(`Entity: ${entity.data.canonical_name}`);
console.log(`First seen: ${entity.data.first_seen}`);
console.log(`Mentions: ${entity.data.mention_count}`);
// Observations
entity.observations.forEach(obs => {
for (const obs of entity.data.observations) {
console.log(` - ${obs.text}`);
});
// Include related facts
const withFacts = await client.getEntity({
bankId: 'my-bank',
entityId: 'entity-uuid',
includeFacts: true,
maxFacts: 20
});
}
```
</TabItem>
@@ -163,9 +178,6 @@ const withFacts = await client.getEntity({
```bash
# Get entity details
hindsight entities get my-bank entity-uuid
# With related facts
hindsight entities get my-bank entity-uuid --include-facts
```
</TabItem>
@@ -185,50 +197,30 @@ Observations are high-level summaries automatically synthesized from multiple fa
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>
<TabItem value="python" label="Python">
```python
# Search by name
entities = client.search_entities(
# Regenerate observations for an entity
api.regenerate_entity_observations(
bank_id="my-bank",
query="Alice"
)
# Fuzzy matching handles variations
entities = client.search_entities(
bank_id="my-bank",
query="Alic" # Matches "Alice", "Alicia", etc.
entity_id="entity-uuid"
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
// Search by name
const entities = await client.searchEntities({
bankId: 'my-bank',
query: 'Alice'
```typescript
// Regenerate observations
await sdk.regenerateEntityObservations({
client: apiClient,
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>
@@ -239,7 +231,6 @@ hindsight entities search my-bank "Alice"
```json
{
"id": "entity-uuid",
"name": "Alice Chen",
"canonical_name": "Alice Chen",
"first_seen": "2024-01-15T10:30:00Z",
"last_seen": "2024-03-20T14:22:00Z",
@@ -247,7 +238,7 @@ hindsight entities search my-bank "Alice"
"observations": [
{
"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"
}
]
}
+118 -106
View File
@@ -2,14 +2,14 @@
sidebar_position: 6
---
# Memory bank Identity
# Memory Bank Identity
Configure memory bank personality, background, and behavior.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## Creating an Memory bank
## Creating a Memory Bank
<Tabs>
<TabItem value="python" label="Python">
@@ -19,8 +19,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.create_agent(
agent_id="my-agent",
client.create_bank(
bank_id="my-bank",
name="Research Assistant",
background="I am a research assistant specializing in machine learning",
personality={
@@ -38,11 +38,11 @@ client.create_agent(
<TabItem value="node" label="Node.js">
```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',
background: 'I am a research assistant specializing in machine learning',
personality: {
@@ -61,10 +61,10 @@ await ManagementService.createAgentApiAgentsAgentIdPut('my-agent', {
```bash
# 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
hindsight memory bank personality my-agent \
hindsight agent personality my-bank \
--openness 0.8 \
--conscientiousness 0.7 \
--extraversion 0.5 \
@@ -90,72 +90,83 @@ Each trait is scored 0.0 to 1.0:
### 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
# High openness agent
# High openness bank
"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."
```
**Conscientiousness** affects structure and thoroughness:
```python
# High conscientiousness agent
# High conscientiousness bank
"Here's a detailed, step-by-step analysis..."
# Low conscientiousness agent
# Low conscientiousness bank
"Quick take: this should work, let's try it."
```
**Extraversion** shapes collaboration preferences:
```python
# High extraversion agent
# High extraversion bank
"We should get the team together to discuss this."
# Low extraversion agent
# Low extraversion bank
"I'll analyze this independently and share my findings."
```
**Agreeableness** affects how disagreements are handled:
```python
# High agreeableness agent
# High agreeableness bank
"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."
```
**Neuroticism** influences risk assessment:
```python
# High neuroticism agent
# High neuroticism bank
"We should consider what could go wrong here..."
# Low neuroticism agent
# Low neuroticism bank
"The risks seem manageable, let's proceed."
```
## Background
The background is a first-person narrative providing agent context:
The background is a first-person narrative providing bank context:
<Tabs>
<TabItem value="python" label="Python">
```python
client.create_agent(
agent_id="financial-advisor",
client.create_bank(
bank_id="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 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>
</Tabs>
@@ -164,91 +175,43 @@ Background influences:
- Perspective in responses
- Opinion formation context
### Merging Background
New background information is merged intelligently:
## Getting Bank Profile
<Tabs>
<TabItem value="python" label="Python">
```python
# Original background
client.create_agent(
agent_id="assistant",
background="I am a helpful AI assistant"
)
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
# Add more context (merged, not replaced)
client.update_background(
agent_id="assistant",
background="I specialize in Python programming"
)
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# 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>
</Tabs>
<TabItem value="node" label="Node.js">
Merging rules:
- **Conflicts**: New overwrites old
- **Additions**: Non-conflicting info is added
- **Normalization**: "You are..." → "I am..."
```typescript
const profile = await client.getBankProfile('my-bank');
## Getting Memory bank Profile
<Tabs>
<TabItem value="python" label="Python">
```python
profile = client.get_profile(agent_id="my-agent")
print(f"Background: {profile['background']}")
print(f"Personality: {profile['personality']}")
console.log(`Name: ${profile.name}`);
console.log(`Background: ${profile.background}`);
console.log(`Personality:`, profile.personality);
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight memory bank profile my-agent
```
</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
hindsight agent profile my-bank
```
</TabItem>
@@ -256,7 +219,7 @@ hindsight agent list
## Default Values
If not specified, memory banks use neutral defaults:
If not specified, banks use neutral defaults:
```python
{
@@ -287,9 +250,9 @@ Common personality configurations:
<TabItem value="python" label="Python">
```python
# Customer support agent
client.create_agent(
agent_id="support",
# Customer support bank
client.create_bank(
bank_id="support",
background="I am a friendly customer support agent",
personality={
"openness": 0.5,
@@ -301,9 +264,9 @@ client.create_agent(
}
)
# Code reviewer agent
client.create_agent(
agent_id="reviewer",
# Code reviewer bank
client.create_bank(
bank_id="reviewer",
background="I am a thorough code reviewer focused on quality",
personality={
"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>
</Tabs>
## Memory bank Isolation
## Bank Isolation
Each agent has:
- **Separate memories** — memory banks don't share memories
- **Own personality** — traits are per-agent
Each bank has:
- **Separate memories** — banks don't share memories
- **Own personality** — traits are per-bank
- **Independent opinions** — formed from their own experiences
```python
# Store to agent A
client.store(agent_id="agent-a", content="Python is great")
<Tabs>
<TabItem value="python" label="Python">
# Memory bank B doesn't see it
results = client.search(agent_id="agent-b", query="Python")
```python
# Store to bank A
client.retain(bank_id="bank-a", content="Python is great")
# Bank B doesn't see it
results = client.recall(bank_id="bank-b", query="Python")
# Returns empty
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
// Store to bank A
await client.retain('bank-a', 'Python is great');
// Bank B doesn't see it
const results = await client.recall('bank-b', 'Python');
// Returns empty
```
</TabItem>
</Tabs>
+125 -153
View File
@@ -31,84 +31,40 @@ For large content batches, use async mode to avoid timeouts:
<TabItem value="python" label="Python">
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Start async batch retain
operation = client.retain_batch_async(
result = client.retain_batch(
bank_id="my-bank",
contents=[
items=[
{"content": doc1_text},
{"content": doc2_text},
# ... hundreds or thousands of documents
]
],
async_=True # Enable async mode
)
print(f"Operation ID: {operation['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")
print(f"Operation ID: {result.get('operation_id')}")
```
</TabItem>
<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
const operation = await client.retainBatchAsync({
bankId: 'my-bank',
contents: [
{ content: doc1Text },
{ content: doc2Text },
// ... hundreds or thousands of documents
]
});
const result = await client.retainBatch('my-bank', [
{ content: doc1Text },
{ content: doc2Text },
// ... hundreds or thousands of documents
], { async: true });
console.log(`Operation ID: ${operation.operationId}`);
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`);
}
console.log(`Operation ID: ${result.operation_id}`);
```
</TabItem>
@@ -119,12 +75,6 @@ if (finalStatus.status === 'completed') {
hindsight retain my-bank --files docs/*.md --async
# Returns operation ID: op-abc123...
# Check status
hindsight operations get my-bank op-abc123
# Watch progress
hindsight operations watch my-bank op-abc123
```
</TabItem>
@@ -138,54 +88,45 @@ View all operations for a memory bank:
<TabItem value="python" label="Python">
```python
# Using the low-level API
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DefaultApi
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DefaultApi(api_client)
# List all operations
operations = client.list_operations(bank_id="my-bank")
response = api.list_operations(bank_id="my-bank")
for op in operations:
print(f"{op['operation_id']}: {op['type']} - {op['status']}")
if op['status'] == 'running':
print(f" Progress: {op['progress']}/{op['total']}")
# 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
)
for op in response.items:
print(f"{op.id}: {op.task_type} - {op.status}")
print(f" Items: {op.items_count}")
if op.error_message:
print(f" Error: {op.error_message}")
```
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
```typescript
import { sdk, createClient, createConfig } from '@hindsight/client';
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// List all operations
const operations = await client.listOperations({
bankId: 'my-bank'
const response = await sdk.listOperations({
client: apiClient,
path: { bank_id: 'my-bank' }
});
operations.forEach(op => {
console.log(`${op.operationId}: ${op.type} - ${op.status}`);
if (op.status === 'running') {
console.log(` Progress: ${op.progress}/${op.total}`);
for (const op of response.data.items) {
console.log(`${op.id}: ${op.task_type} - ${op.status}`);
console.log(` Items: ${op.items_count}`);
if (op.error_message) {
console.log(` Error: ${op.error_message}`);
}
});
// Filter by status
const pending = await client.listOperations({
bankId: 'my-bank',
status: 'pending'
});
}
```
</TabItem>
@@ -214,38 +155,41 @@ Stop a running or pending operation:
```python
# Cancel operation
client.cancel_operation(
api.cancel_operation(
bank_id="my-bank",
operation_id="op-abc123"
)
# Cancel all pending operations
operations = client.list_operations(bank_id="my-bank", status="pending")
for op in operations:
client.cancel_operation(bank_id="my-bank", operation_id=op['operation_id'])
response = api.list_operations(bank_id="my-bank")
for op in response.items:
if op.status == "pending":
api.cancel_operation(bank_id="my-bank", operation_id=op.id)
```
</TabItem>
<TabItem value="node" label="Node.js">
```javascript
```typescript
// Cancel operation
await client.cancelOperation({
bankId: 'my-bank',
operationId: 'op-abc123'
await sdk.cancelOperation({
client: apiClient,
path: { bank_id: 'my-bank', operation_id: 'op-abc123' }
});
// Cancel all pending
const pending = await client.listOperations({
bankId: 'my-bank',
status: 'pending'
const ops = await sdk.listOperations({
client: apiClient,
path: { bank_id: 'my-bank' }
});
for (const op of pending) {
await client.cancelOperation({
bankId: 'my-bank',
operationId: op.operationId
});
for (const op of ops.data.items) {
if (op.status === 'pending') {
await sdk.cancelOperation({
client: apiClient,
path: { bank_id: 'my-bank', operation_id: op.id }
});
}
}
```
@@ -285,17 +229,14 @@ hindsight operations cancel my-bank --all-pending
```json
{
"operation_id": "op-abc123",
"id": "op-abc123",
"bank_id": "my-bank",
"type": "batch_retain",
"status": "running",
"progress": 450,
"total": 1000,
"task_type": "batch_retain",
"status": "completed",
"items_count": 1000,
"document_id": "batch-001",
"created_at": "2024-03-15T10:00:00Z",
"started_at": "2024-03-15T10:00:05Z",
"completed_at": null,
"error": null,
"result": null
"error_message": null
}
```
@@ -303,39 +244,70 @@ hindsight operations cancel my-bank --all-pending
### Polling
<Tabs>
<TabItem value="python" label="Python">
```python
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:
status = client.get_operation(bank_id=bank_id, operation_id=operation_id)
response = api.list_operations(bank_id=bank_id)
if status['status'] == 'completed':
return status['result']
elif status['status'] == 'failed':
raise Exception(f"Operation failed: {status['error']}")
elif status['status'] == 'cancelled':
raise Exception("Operation was cancelled")
pending_or_running = [
op for op in response.items
if op.status in ['pending', 'running']
]
if not pending_or_running:
print("All operations completed!")
break
for op in pending_or_running:
print(f" {op.id}: {op.status} ({op.items_count} items)")
print(f"Progress: {status['progress']}/{status['total']}")
time.sleep(poll_interval)
# Use it
result = wait_for_operation(client, "my-bank", op_id)
print(f"Created {len(result['memory_ids'])} memories")
wait_for_operations(api, "my-bank")
```
### Webhooks (Coming Soon)
</TabItem>
<TabItem value="node" label="Node.js">
```python
# Configure webhook for operation completion
client.configure_webhook(
bank_id="my-bank",
url="https://myapp.com/webhooks/hindsight",
events=["operation.completed", "operation.failed"]
)
```typescript
async function waitForOperations(apiClient: any, bankId: string, pollInterval = 5000) {
while (true) {
const response = await sdk.listOperations({
client: apiClient,
path: { bank_id: bankId }
});
const pendingOrRunning = response.data.items.filter(
(op: any) => ['pending', 'running'].includes(op.status)
);
if (pendingOrRunning.length === 0) {
console.log('All operations completed!');
break;
}
for (const op of pendingOrRunning) {
console.log(` ${op.id}: ${op.status} (${op.items_count} items)`);
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
}
// Use it
await waitForOperations(apiClient, 'my-bank');
```
</TabItem>
</Tabs>
## Performance Tips
**Use async for large batches:**
@@ -343,11 +315,11 @@ client.configure_webhook(
- Async: > 100 items or > 100KB
**Monitor progress:**
- Check `progress` / `total` fields
- Check `items_count` field
- Poll every 5-10 seconds
**Handle failures:**
- Check `error` field for details
- Check `error_message` field for details
- Retry with exponential backoff
- Break large batches into smaller chunks
+115 -45
View File
@@ -19,8 +19,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
results = client.search(
agent_id="my-agent",
results = client.recall(
bank_id="my-bank",
query="What does Alice do?"
)
@@ -32,16 +32,13 @@ for r in results:
<TabItem value="node" label="Node.js">
```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({
agent_id: 'my-agent',
query: 'What does Alice do?'
});
const results = await client.recall('my-bank', 'What does Alice do?');
for (const r of results.results) {
for (const r of results) {
console.log(`${r.text} (score: ${r.weight})`);
}
```
@@ -50,7 +47,7 @@ for (const r of results.results) {
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-agent "What does Alice do?"
hindsight memory search my-bank "What does Alice do?"
```
</TabItem>
@@ -61,27 +58,85 @@ hindsight memory search my-agent "What does Alice do?"
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | string | required | Natural language query |
| `top_k` | int | 10 | Maximum results to return |
| `budget` | Budget | MID | Budget level: LOW (100), MID (300), HIGH (600) nodes |
| `fact_type` | list | all | Filter: `world`, `agent`, `opinion` |
| `types` | list | all | Filter: `world`, `agent`, `opinion` |
| `budget` | string | "mid" | Budget level: "low", "mid", "high" |
| `max_tokens` | int | 4096 | Token budget for results |
<Tabs>
<TabItem value="python" label="Python">
```python
from hindsight_api.engine.memory_engine import Budget
results = client.recall(
bank_id="my-agent",
bank_id="my-bank",
query="What does Alice do?",
top_k=20,
budget=Budget.HIGH,
fact_type=["world", "agent"],
types=["world", "agent"],
budget="high",
max_tokens=8000
)
```
</TabItem>
<TabItem value="node" label="Node.js">
```typescript
const results = await client.recall('my-bank', 'What does Alice do?', {
budget: 'high',
maxTokens: 8000
});
```
</TabItem>
</Tabs>
## Full-Featured Search
For more control, use the full-featured recall method:
<Tabs>
<TabItem value="python" label="Python">
```python
# Full response with trace info
response = client.recall_memories(
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>
</Tabs>
@@ -94,17 +149,17 @@ Hindsight automatically detects time expressions and activates temporal search:
```python
# These queries activate temporal-graph retrieval
results = client.search(agent_id="my-agent", query="What did Alice do last spring?")
results = client.search(agent_id="my-agent", 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="What did Alice do last spring?")
results = client.recall(bank_id="my-bank", query="What happened in June?")
results = client.recall(bank_id="my-bank", query="Events from last year")
```
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-agent "What did Alice do last spring?"
hindsight memory search my-agent "What happened between March and May?"
hindsight memory search my-bank "What did Alice do last spring?"
hindsight memory search my-bank "What happened between March and May?"
```
</TabItem>
@@ -129,31 +184,31 @@ Search specific memory networks:
```python
# Only world facts (objective information)
world_facts = client.search_memories(
agent_id="my-agent",
world_facts = client.recall(
bank_id="my-bank",
query="Where does Alice work?",
fact_type=["world"]
types=["world"]
)
# Only agent facts (memory bank's own experiences)
agent_facts = client.search_memories(
agent_id="my-agent",
agent_facts = client.recall(
bank_id="my-bank",
query="What have I recommended?",
fact_type=["agent"]
types=["agent"]
)
# Only opinions (formed beliefs)
opinions = client.search_memories(
agent_id="my-agent",
opinions = client.recall(
bank_id="my-bank",
query="What do I think about Python?",
fact_type=["opinion"]
types=["opinion"]
)
# World and agent facts (exclude opinions)
facts = client.search_memories(
agent_id="my-agent",
facts = client.recall(
bank_id="my-bank",
query="What happened?",
fact_type=["world", "agent"]
types=["world", "agent"]
)
```
@@ -161,8 +216,8 @@ facts = client.search_memories(
<TabItem value="cli" label="CLI">
```bash
hindsight memory search my-agent "Python" --fact-type opinion
hindsight memory search my-agent "Alice" --fact-type world,agent
hindsight memory search my-bank "Python" --fact-type opinion
hindsight memory search my-bank "Alice" --fact-type world,agent
```
</TabItem>
@@ -225,16 +280,31 @@ graph LR
The `budget` parameter controls graph traversal depth:
- **Budget.LOW (100 nodes)**: Fast, shallow search — good for simple lookups
- **Budget.MID (300 nodes)**: Balanced — default for most queries
- **Budget.HIGH (600 nodes)**: Deep exploration — finds indirect connections
- **"low" (100 nodes)**: Fast, shallow search — good for simple lookups
- **"mid" (300 nodes)**: Balanced — default for most queries
- **"high" (600 nodes)**: Deep exploration — finds indirect connections
<Tabs>
<TabItem value="python" label="Python">
```python
from hindsight_api.engine.memory_engine import Budget
# 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
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>
+101 -56
View File
@@ -2,7 +2,7 @@
sidebar_position: 3
---
# Think
# Reflect
Generate personality-aware responses using retrieved memories.
@@ -19,38 +19,35 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
answer = client.think(
agent_id="my-agent",
response = client.reflect(
bank_id="my-bank",
query="What should I know about Alice?"
)
print(answer["text"])
print(response["answer"])
```
</TabItem>
<TabItem value="node" label="Node.js">
```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({
agent_id: 'my-agent',
query: 'What should I know about Alice?'
});
const response = await client.reflect('my-bank', 'What should I know about Alice?');
console.log(response.text);
console.log(response.answer);
```
</TabItem>
<TabItem value="cli" label="CLI">
```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
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>
@@ -60,26 +57,21 @@ hindsight memory think my-agent "What should I know about Alice?" -v
```python
{
"text": "Alice is a software engineer at Google who joined last year...",
"based_on": {
"world": [
{"text": "Alice works at Google", "weight": 0.95, "id": "..."}
],
"agent": [],
"opinion": [
{"text": "Alice is very competent", "weight": 0.82, "id": "..."}
]
},
"answer": "Alice is a software engineer at Google who joined last year...",
"facts_used": [
{"text": "Alice works at Google", "weight": 0.95, "id": "..."},
{"text": "Alice is very competent", "weight": 0.82, "id": "..."}
],
"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 |
|-------|-------------|
| `text` | Generated response |
| `based_on` | Memories used, grouped by type |
| `answer` | Generated response |
| `facts_used` | Memories used in generation |
| `new_opinions` | New opinions formed during reasoning |
## Parameters
@@ -87,26 +79,35 @@ hindsight memory think my-agent "What should I know about Alice?" -v
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | string | required | Question or prompt |
| `budget` | Budget | LOW | Budget level: LOW (100), MID (300), HIGH (600) nodes |
| `top_k` | int | 10 | Max memories to retrieve |
| `budget` | string | "low" | Budget level: "low", "mid", "high" |
| `context` | string | None | Additional context for the query |
<Tabs>
<TabItem value="python" label="Python">
```python
from hindsight_api.engine.memory_engine import Budget
answer = client.reflect(
bank_id="my-agent",
response = client.reflect(
bank_id="my-bank",
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>
</Tabs>
## What Think Does
## What Reflect Does
```mermaid
sequenceDiagram
@@ -115,10 +116,10 @@ sequenceDiagram
participant M as Memory Store
participant L as LLM
C->>A: think("What about Alice?")
C->>A: reflect("What about Alice?")
A->>M: Search all networks
M-->>A: World + Memory bank + Opinion facts
A->>A: Load memory bank personality
M-->>A: World + Bank + Opinion facts
A->>A: Load bank personality
A->>L: Generate with personality context
L-->>A: Response + new opinions
A->>M: Store new opinions
@@ -126,28 +127,28 @@ sequenceDiagram
```
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
4. **Forms opinions** if the query warrants it
5. **Returns** response with sources and any new opinions
## Opinion Formation
Think can form new opinions based on evidence:
Reflect can form new opinions based on evidence:
<Tabs>
<TabItem value="python" label="Python">
```python
answer = client.think(
agent_id="my-agent",
response = client.reflect(
bank_id="my-bank",
query="What do you think about Python vs JavaScript for data science?"
)
# 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: [
# {"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
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 **Conscientiousness** | More structured, methodical responses |
@@ -168,10 +169,13 @@ The memory bank's personality affects Think responses:
| High **Agreeableness** | More diplomatic, harmony-seeking |
| High **Neuroticism** | More risk-aware, cautious |
<Tabs>
<TabItem value="python" label="Python">
```python
# Create a memory bank with specific personality
client.create_agent(
agent_id="cautious-advisor",
# Create a bank with specific personality
client.create_bank(
bank_id="cautious-advisor",
background="I am a risk-aware financial advisor",
personality={
"openness": 0.3,
@@ -181,28 +185,69 @@ client.create_agent(
}
)
# Think responses will reflect this personality
answer = client.think(
agent_id="cautious-advisor",
# Reflect responses will reflect this personality
response = client.reflect(
bank_id="cautious-advisor",
query="Should I invest in crypto?"
)
# 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
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
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:")
for fact in answer["based_on"]["world"]:
for fact in response.get("facts_used", []):
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:
- **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
- **Debugging** — understand retrieval quality
+51 -34
View File
@@ -45,8 +45,8 @@ from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.store(
agent_id="my-agent",
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
```
@@ -55,21 +55,18 @@ client.store(
<TabItem value="node" label="Node.js">
```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({
agent_id: 'my-agent',
content: 'Alice works at Google as a software engineer'
});
await client.retain('my-bank', 'Alice works at Google as a software engineer');
```
</TabItem>
<TabItem value="cli" label="CLI">
```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>
@@ -83,19 +80,29 @@ Add context and event dates for better retrieval:
<TabItem value="python" label="Python">
```python
client.store(
agent_id="my-agent",
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
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 value="cli" label="CLI">
```bash
hindsight memory put my-agent "Alice got promoted" \
hindsight memory put my-bank "Alice got promoted" \
--context "career update" \
--event-date "2024-03-15"
```
@@ -103,7 +110,7 @@ hindsight memory put my-agent "Alice got promoted" \
</TabItem>
</Tabs>
The `event_date` enables temporal queries like "What happened last spring?"
The `timestamp` enables temporal queries like "What happened last spring?"
## Batch Ingestion
@@ -113,8 +120,8 @@ Store multiple memories in a single request:
<TabItem value="python" label="Python">
```python
client.store_batch(
agent_id="my-agent",
client.retain_batch(
bank_id="my-bank",
items=[
{"content": "Alice works at Google", "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">
```typescript
await MemoryStorageService.batchApiMemoriesBatchPost({
agent_id: 'my-agent',
items: [
{ content: 'Alice works at Google', context: 'career' },
{ content: 'Bob is a data scientist at Meta', context: 'career' }
],
document_id: 'conversation_001'
});
await client.retainBatch('my-bank', [
{ content: 'Alice works at Google', context: 'career' },
{ content: 'Bob is a data scientist at Meta', context: 'career' },
{ content: 'Alice and Bob are friends', context: 'relationship' }
], { documentId: 'conversation_001' });
```
</TabItem>
@@ -150,13 +154,13 @@ The `document_id` groups related memories for later management.
```bash
# Single file
hindsight memory put-files my-agent document.txt
hindsight memory put-files my-bank document.txt
# 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
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>
@@ -191,15 +195,28 @@ For large batches, use async ingestion:
```python
# Start async ingestion
operation = client.store_batch_async(
agent_id="my-agent",
result = client.retain_batch(
bank_id="my-bank",
items=[...large batch...],
document_id="large-doc"
document_id="large-doc",
async_=True
)
# Check status
status = client.get_operation(operation["operation_id"])
print(status["status"]) # "pending", "processing", "completed", "failed"
# Result contains operation_id for tracking
print(result["operation_id"])
```
</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>
@@ -211,5 +228,5 @@ print(status["status"]) # "pending", "processing", "completed", "failed"
|----|-------|
| Include context for better retrieval | Store raw unstructured dumps |
| 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 |
+1 -1
View File
@@ -63,7 +63,7 @@ class Server:
host: str = "127.0.0.1",
port: Optional[int] = None,
mcp_enabled: bool = False,
log_level: str = "warning",
log_level: str = "info",
):
"""
Initialize the Hindsight server.