Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 344ac8fae8 test: add client tests for ReflectResponse parsing
Added comprehensive tests in hindsight-clients/python/tests to verify:
- v0.4.0+ format with empty based_on object
- v0.4.0+ format with null based_on
- v0.4.0+ format with populated facts
- v0.3.0 format (list) correctly fails validation
- Missing based_on field handling

These tests document the v0.3.0 -> v0.4.0 breaking change where
based_on changed from list to object.
2026-02-12 10:06:20 +01:00
Nicolò Boschi 4b0c617ecf fix: remove client imports from API test
The test was failing in CI because it imported the client library
which isn't installed in the API test environment.

Changed to test only API JSON response format, not client parsing.
This is more appropriate for an API test anyway.
2026-02-12 10:05:08 +01:00
Nicolò Boschi 0a04770450 fix: add default values to OpenAPI schema for default_factory fields
This commit fixes the OpenAPI schema to include default values for fields
using default_factory, which improves schema accuracy and client generation.

Changes:
1. Added FieldWithDefault() helper to inject default values into OpenAPI schema
2. Updated 14 fields using default_factory to include defaults in schema:
   - ReflectBasedOn.{memories, mental_models, directives}
   - ReflectTrace.{tool_calls, llm_calls}
   - All tags fields
   - All trigger fields
   - All include fields

3. Regenerated OpenAPI spec with proper defaults

4. Added tests to verify API returns correct format with empty banks

Note: This fixes the schema but doesn't change the v0.3.0 -> v0.4.0 breaking
change where based_on went from list to object. Clients should handle both
formats for backward compatibility.
2026-02-11 17:51:09 +01:00
Nicolò Boschi 60574ee08f fix: add trust_code env config (#347)
* fix: add trust_code env config

* doc
2026-02-11 17:06:59 +01:00
Nicolò Boschi 7d95a002c7 fix: improve model configuration for litellm gateway (#345)
* fix: improve model configuration for litellm gateway

* fix: add missing config imports for Cohere and LiteLLM providers

Add missing DEFAULT_* and ENV_* constants to cross_encoder.py and embeddings.py imports:
- DEFAULT_RERANKER_COHERE_MODEL
- DEFAULT_LITELLM_API_BASE
- DEFAULT_RERANKER_LITELLM_MODEL
- DEFAULT_EMBEDDINGS_COHERE_MODEL
- DEFAULT_EMBEDDINGS_LITELLM_MODEL
- ENV_RERANKER_COHERE_MODEL

This fixes NameError failures in test-api, test-hindsight-all, and test-upgrade CI jobs.
2026-02-11 11:24:26 +01:00
Chris Bartholomew 83ca669011 Add actual LLM token usage fields to RetainResult (#342)
* Add actual LLM token usage fields to RetainResult

RetainResult now carries llm_input_tokens, llm_output_tokens, and
llm_total_tokens populated from the engine's TokenUsage, so downstream
operation validator extensions can access actual LLM token counts.

* Test that RetainResult includes actual LLM token usage
2026-02-11 10:41:41 +01:00
DK09876andClaude Opus 4.6 e798979733 Harden MCP server: fix routing, validation, and usage metering (#341)
* fix: move mental model usage metering into engine for MCP support

Mental model validation hooks (validate_mental_model_get, validate_mental_model_refresh)
were only called in REST HTTP handlers, not in the engine. MCP tools call engine methods
directly, so usage metering was skipped entirely for MCP mental model operations.

Moved pre-validation and post-completion hooks into memory_engine.py (matching the
retain/recall/reflect pattern) and removed the duplicate code from http.py.

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

* fix: remove double validation from create_mental_model and add internal checks

- Remove pre-validation from create_mental_model since callers always call
  submit_async_refresh_mental_model next (which validates), preventing
  double credit checks
- Add is_internal checks to mental model metering validators (matching
  the existing pattern for recall/reflect) so background worker tasks
  skip billing

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

* fix: prevent 307 redirect on /mcp that breaks MCP tool discovery

Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary
Redirect. Many MCP clients don't follow POST redirects, which causes
tool discovery to fail (0 tools discovered despite successful auth).

Add _MCPPathRewriteMiddleware that rewrites /mcp to /mcp/ at the ASGI
level before routing, preventing the redirect entirely. Both /mcp and
/mcp/ now work identically.

Add regression test test_mcp_no_trailing_slash_works to verify URLs
with and without trailing slashes discover tools correctly.

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

* harden MCP server for real-world usage

- Remove MCP_ENDPOINTS blocklist so banks named "sse"/"messages" route correctly
- Scope SSE body rewriting to text/event-stream responses only to prevent data corruption
- Add _validate_mental_model_inputs for name, source_query, max_tokens validation in MCP tools
- Improve "not found" error messages to include bank_id context
- Fix fragile tool count assertions (exact → minimum bounds)
- Add integration tests: tool execution, input validation, edge-case bank names
- Add unit tests for validation helper and tool-level validation

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

* refactor: replace Mount + rewrite middleware with wrapping middleware

Starlette's Mount class redirects /mcp -> /mcp/ with 307, which MCP clients
don't follow. Previously we patched this with _MCPPathRewriteMiddleware.

Now MCPMiddleware wraps the FastAPI app directly via add_middleware, intercepting
/mcp* requests before they reach Starlette's router. No Mount means no redirect.

- Remove _MCPPathRewriteMiddleware (no longer needed)
- Remove app.mount() call
- Add prefix parameter to MCPMiddleware
- Use app.add_middleware() for proper Starlette integration
- Simplify path stripping (just remove prefix, no mount/root_path handling)
- Update routing test to match current behavior (no MCP_ENDPOINTS blocklist)

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

* fix: update stale docstring referencing removed _MCPPathRewriteMiddleware

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-11 10:41:20 +01:00
Anton EvseevandClaude Opus 4.6 43f9a8bec2 feat(helm): TEI reranker and embedding as separate Deployments (#333)
Refactor TEI from sidecar (PR #333) to standalone Deployment+Service
pairs for independent scaling. Adds embedding support alongside reranker.

- New tei-reranker-deployment.yaml and tei-reranker-service.yaml
- New tei-embedding-deployment.yaml and tei-embedding-service.yaml
- Auto-inject RERANKER/EMBEDDINGS provider and URL env vars on API pod
- Config restructured under tei.reranker.* and tei.embedding.* in values
- Both disabled by default, opt-in via tei.reranker.enabled / tei.embedding.enabled

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-11 10:39:41 +01:00
DK09876andClaude Opus 4.6 f641b30d83 feat: add mental model CRUD tools to MCP server (#337)
* Add mental model CRUD tools to MCP server

Expose mental models (pinned reflections) as 6 new MCP tools:
- list_mental_models: List with optional tag filtering
- get_mental_model: Get by ID
- create_mental_model: Create with async content generation
- update_mental_model: Update name/source_query/tags
- delete_mental_model: Delete by ID
- refresh_mental_model: Re-run source query to update content

Both multi-bank (bank_id param) and single-bank modes supported,
following the same patterns as existing retain/recall/reflect tools.

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

* fix: include mental model tools in single-bank MCP mode and update tests

The single-bank mode tool set was hardcoded to only retain/recall/reflect,
excluding the new mental model tools. Updated all 3 test layers (unit,
routing, HTTP integration) to assert mental model tool exposure.

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

* fix: update extension test tool count for mental model tools

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

* fix: move mental model usage metering into engine for MCP support

Mental model validation hooks (validate_mental_model_get, validate_mental_model_refresh)
were only called in REST HTTP handlers, not in the engine. MCP tools call engine methods
directly, so usage metering was skipped entirely for MCP mental model operations.

Moved pre-validation and post-completion hooks into memory_engine.py (matching the
retain/recall/reflect pattern) and removed the duplicate code from http.py.

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

* fix: remove double validation from create_mental_model and add internal checks

- Remove pre-validation from create_mental_model since callers always call
  submit_async_refresh_mental_model next (which validates), preventing
  double credit checks
- Add is_internal checks to mental model metering validators (matching
  the existing pattern for recall/reflect) so background worker tasks
  skip billing

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 22:40:43 +01:00
Chris Bartholomew 90be7c6829 Add user_initiated flag to RequestContext for async task attribution (#338)
Async batch retain tasks need internal=True to bypass extension auth
(worker has no API key), but extensions also need to know the operation
originated from a user request. The new user_initiated flag on
RequestContext allows extensions to distinguish user-initiated async
operations from truly internal system operations like consolidation.
2026-02-10 22:37:42 +01:00
Nicolò Boschi 6eec83b20d fix: include tiktoken in slim image (#336) 2026-02-10 17:26:38 +01:00
Nicolò Boschi dd1e0986a1 feat: add docs skill (#335)
* feat: add docs skill

* feat: add docs skill
2026-02-10 14:41:51 +01:00
Nicolò Boschi 69dec8ec34 feat: add otel traceability (#330)
* feat: add comprehensive OpenTelemetry tracing

- Add tool execution spans for reflect operations
- Add tool call information (names, params) to spans
- Change verification scope from 'test' to 'verification'
- Add hindsight.reflect_generation span for done() processing
- Implement no-op tracer for improved code readability
- Update documentation for OTEL configuration
- Resolve merge conflicts from rebase

* fix: properly serialize Pydantic models in span recording

- Add _serialize_for_span() helper to handle Pydantic models
- Update all providers to use the helper function
- Fixes test failures with 'Object of type X is not JSON serializable'

* feat: add Grafana LGTM stack for unified local observability

Add Grafana LGTM (Loki, Grafana, Tempo, Mimir) as the recommended
local development observability stack. This provides traces, metrics,
and logs in a single Docker container instead of separate tools.

Changes:
- Add scripts/dev/grafana/ with docker-compose and README
- Add scripts/dev/start-grafana.sh startup script
- Update .env.example to reference Grafana LGTM
- Update configuration docs to emphasize Grafana LGTM as primary option
- Reorder OTLP backend list to show Grafana LGTM first

Benefits:
- Single container vs multiple separate tools (Jaeger, SigNoz, etc.)
- ~515MB image with full observability stack
- Compatible with existing OTLP configuration
- Simpler local development setup

* chore: remove SigNoz scripts and references

Remove SigNoz observability stack in favor of Grafana LGTM as the
sole recommended local development tracing solution.

Changes:
- Delete scripts/dev/signoz/ directory and all SigNoz configurations
- Delete scripts/dev/start-signoz.sh startup script
- Remove SigNoz references from .env.example
- Remove SigNoz from OTLP backends list in configuration docs

Grafana LGTM provides the same capabilities (traces, metrics, logs)
in a simpler single-container setup.

* feat: add consolidation span hierarchy for tracing

Add parent-child span structure for consolidation operations:
- hindsight.consolidation: Parent span for each memory being processed
- hindsight.consolidation_recall: Child span for finding related observations
- LLM call span: Automatically created by LLM provider (scope="consolidation")

This enables detailed timing breakdown in Grafana Tempo:
- Total consolidation time per memory
- Time spent in recall
- Time spent in LLM call
- Time spent executing actions (create/update)

All consolidation tests pass (31/31).

* feat: add Prometheus metrics and GenAI dashboard to Grafana stack

Add comprehensive metrics and dashboarding to the Grafana LGTM stack:

Metrics Collection:
- Configure Prometheus to scrape Hindsight API /metrics endpoint
- Scrape interval: 10 seconds
- Targets hindsight-api on host.docker.internal:8888

GenAI Dashboard:
- Pre-configured dashboard with 6 panels:
  - LLM call rate (by provider/model)
  - LLM call duration (p50/p95 by scope)
  - Token usage - input tokens/sec by scope
  - Token usage - output tokens/sec by scope
  - Operations rate (retain/recall/reflect/consolidation)
  - Operation duration p95 by operation type

Configuration:
- Mount prometheus.yml for metrics scraping
- Mount dashboards directory for auto-provisioning
- Add host.docker.internal mapping for container->host access
- Dashboard provisioning with auto-reload every 10s

Documentation:
- Updated README with metrics viewing instructions
- Added PromQL query examples
- Documented dashboard access and navigation

This provides full observability: traces (Tempo) + metrics (Prometheus/Mimir) + dashboards (Grafana)

* refactor: merge Grafana setup into existing monitoring stack

Consolidate the separate scripts/dev/grafana/ setup into the existing
scripts/dev/monitoring/ stack, using Grafana LGTM (Loki, Grafana, Tempo, Mimir).

Changes:
- Remove separate scripts/dev/grafana/ directory and start-grafana.sh
- Rewrite scripts/dev/monitoring/start.sh to use Docker + Grafana LGTM
  (was: download native Prometheus/Grafana binaries)
- Add docker-compose.yaml for Grafana LGTM container
- Add prometheus.yml for scraping Hindsight API metrics
- Mount existing dashboards from monitoring/grafana/dashboards/
- Add comprehensive README.md

Benefits:
- Single unified monitoring command: ./scripts/dev/start-monitoring.sh
- Uses existing dashboard files (hindsight-operations, hindsight-llm, hindsight-api-service)
- Simpler setup: Docker-based vs downloading/running native binaries
- Full observability: traces + metrics + logs + dashboards in one container
- Standard ports: Grafana on 3000, OTLP on 4317/4318

Architecture:
- Grafana LGTM container (~515MB) provides all components
- Dashboards auto-provisioned from monitoring/grafana/dashboards/
- Prometheus scrapes host.docker.internal:8888/metrics
- Shared hindsight-network for future service-to-service tracing

* fix: run monitoring stack in foreground for easy Ctrl+C stop

Change docker-compose from detached (-d) to foreground mode.
Users can now stop the stack with Ctrl+C instead of needing
to run docker-compose down separately.

* fix: remove invalid home dashboard path and obsolete version field

- Remove GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH environment variable
  (was pointing to wrong path causing 'Failed to load home dashboard' error)
- Remove obsolete 'version' field from docker-compose.yaml
  (docker-compose v2+ doesn't require version field)

* fix: load Hindsight dashboards in Grafana LGTM

Mount Hindsight dashboard JSON files and custom provisioning config
to make dashboards visible in Grafana.

Changes:
- Mount hindsight-operations.json, hindsight-llm.json, hindsight-api-service.json to /otel-lgtm/
- Create grafana-dashboards.yaml with all dashboard providers (default + Hindsight)
- Mount custom provisioning config to override LGTM default

All 3 Hindsight dashboards now appear in Grafana UI with metrics
from Prometheus scraping the Hindsight API /metrics endpoint.

* fix: configure Prometheus to scrape Hindsight API metrics

Update prometheus.yml to include both OTLP receiver config (from LGTM)
and scrape_configs for pulling metrics from Hindsight API.

Changes:
- Mount prometheus.yml to /otel-lgtm/prometheus.yaml (where LGTM reads it)
- Add scrape_configs section to pull from host.docker.internal:8888/metrics
- Keep OTLP receiver configuration for trace metrics
- Set scrape_interval to 5s

Verified: Prometheus now successfully scrapes hindsight_llm_calls_total
and other Hindsight metrics. Dashboards now show live data!

* feat: add comprehensive tracing for recall and improve reflect/mental_model_refresh spans

- Add recall operation tracing with parent-child span hierarchy
  - Parent: hindsight.recall with attributes (bank_id, query, fact_types, etc.)
  - Children: recall_embedding, recall_retrieval, recall_fusion, recall_rerank
  - Fixed context propagation using start_as_current_span()

- Improve reflect tracing spans
  - Remove reflect_generation spans, use reflect instead
  - Change done() tool processing to hindsight.reflect_tool_call

- Fix mental_model_refresh span nesting
  - Add _skip_span parameter to reflect_async to avoid duplicate hindsight.reflect spans
  - Mental model refresh now has clean span hierarchy without nested reflect parent

- Add comprehensive tracing verification tests
  - Test span hierarchy and attributes for all operations
  - Verify parent-child relationships
  - 5 passing tests covering recall, reflect, consolidation, and mental_model_refresh

* refactor: remove redundant is_tracing_enabled() checks

- Remove all is_tracing_enabled() conditional checks before tracing calls
- NoOpTracer/NoOpSpan handle disabled tracing automatically
- Simplify code by always calling tracer methods directly
- Fix NoOpTracer.start_as_current_span() to yield NoOpSpan instead of None

Changes:
- memory_engine.py: Remove 5 is_tracing_enabled checks in recall spans
- agent.py: Remove 2 is_tracing_enabled checks in reflect tool spans
- tracing.py: Fix NoOpTracer context manager to yield proper NoOpSpan

This eliminates ~50 lines of redundant conditional code while maintaining
identical behavior.

* docs: simplify distributed tracing section in monitoring.md

- Make tracing documentation more concise
- Focus on span hierarchy and attributes
- Remove verbose troubleshooting and performance sections
- Keep configuration.md for env vars only
2026-02-10 12:20:48 +01:00
DK09876andClaude Opus 4.6 888b50de12 Fix MCP operations not tracked for usage metering (#334)
MCP middleware was discarding tenant_id and api_key_id after authentication.
The authenticate_mcp() call mutated a RequestContext with these fields, but
tools later created a fresh RequestContext without them. This caused
UsageMeteringValidator to see tenant_id="unknown" and skip billing entirely.

Propagate tenant_id and api_key_id via ContextVars (same pattern as bank_id
and api_key) so the RequestContext passed to the memory engine has the full
auth context needed for usage tracking.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 09:38:15 +01:00
121 changed files with 19459 additions and 1094 deletions
+15
View File
@@ -50,3 +50,18 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
# HINDSIGHT_API_OTEL_TRACES_ENABLED=true
#
# Local development with Grafana LGTM stack (recommended - see scripts/dev/grafana/README.md)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
#
# Cloud backends (Grafana Cloud, Langfuse, DataDog, etc.)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend-url
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
#
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
+1
View File
@@ -45,6 +45,7 @@ cd hindsight-control-plane && npm run dev
./scripts/dev/start-docs.sh
```
### Generating Clients/OpenAPI
```bash
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
+10
View File
@@ -42,6 +42,16 @@ If you need more control over how and when your agent stores and recalls memorie
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
---
> 🤖 **Using a coding agent?** Install the Hindsight documentation skill for instant access to docs while you code:
> ```bash
> npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs
> ```
> Works with Claude Code, Cursor, and other AI coding assistants.
---
## Quick Start
+45 -2
View File
@@ -8,6 +8,7 @@
# Set to false when using external providers (TEI, OpenAI, Cohere)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
# Only effective when INCLUDE_LOCAL_MODELS=true
# NOTE: tiktoken encodings are ALWAYS preloaded (required for air-gapped deployments)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
@@ -167,6 +168,28 @@ USER hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -185,7 +208,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
@@ -297,6 +319,28 @@ USER hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -315,7 +359,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
+32
View File
@@ -127,6 +127,38 @@ API URL for control plane
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
{{- end }}
{{/*
TEI reranker labels
*/}}
{{- define "hindsight.tei.reranker.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI reranker selector labels
*/}}
{{- define "hindsight.tei.reranker.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI embedding labels
*/}}
{{- define "hindsight.tei.embedding.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
TEI embedding selector labels
*/}}
{{- define "hindsight.tei.embedding.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
Get the name of the secret to use
*/}}
@@ -67,6 +67,18 @@ spec:
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.tei.reranker.enabled }}
- name: HINDSIGHT_API_RERANKER_PROVIDER
value: "tei"
- name: HINDSIGHT_API_RERANKER_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-reranker:{{ .Values.tei.reranker.port }}"
{{- end }}
{{- if .Values.tei.embedding.enabled }}
- name: HINDSIGHT_API_EMBEDDINGS_PROVIDER
value: "tei"
- name: HINDSIGHT_API_EMBEDDINGS_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-embedding:{{ .Values.tei.embedding.port }}"
{{- end }}
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
{{- if not .Values.existingSecret }}
{{- range $key, $value := .Values.api.secrets }}
@@ -0,0 +1,76 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.embedding.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-embedding
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.embedding.image.repository }}:{{ .Values.tei.embedding.image.tag }}"
imagePullPolicy: {{ .Values.tei.embedding.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.embedding.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.embedding.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.embedding.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.embedding.port | quote }}
{{- range $key, $value := .Values.tei.embedding.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.embedding.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.embedding.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.embedding.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.embedding.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -0,0 +1,76 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.reranker.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-reranker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.reranker.image.repository }}:{{ .Values.tei.reranker.image.tag }}"
imagePullPolicy: {{ .Values.tei.reranker.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.reranker.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.reranker.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.reranker.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.reranker.port | quote }}
{{- range $key, $value := .Values.tei.reranker.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.reranker.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.reranker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.reranker.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.reranker.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 4 }}
{{- end }}
+78
View File
@@ -293,6 +293,84 @@ tolerations: []
# Affinity (applied to all components unless overridden per-component)
affinity: {}
# TEI (Text Embeddings Inference) - optional standalone deployments
# for reranking and/or embedding models
tei:
reranker:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
port: 8090
args:
- "--auto-truncate"
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
embedding:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "sentence-transformers/all-MiniLM-L6-v2"
port: 8091
args: []
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Autoscaling
autoscaling:
enabled: false
+20 -12
View File
@@ -6,7 +6,6 @@ Provides both HTTP REST API and MCP (Model Context Protocol) server.
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI
@@ -46,14 +45,14 @@ def create_app(
# Both HTTP and MCP
app = create_app(memory, mcp_api_enabled=True)
"""
mcp_app = None
mcp_servers = None
# Create MCP app first if enabled (we need its lifespan for chaining)
# Create MCP servers first if enabled (we need their lifespans for chaining)
if mcp_api_enabled:
try:
from .mcp import create_mcp_app
from .mcp import MCPMiddleware, create_mcp_servers
mcp_app = create_mcp_app(memory=memory)
mcp_servers = create_mcp_servers(memory=memory)
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
logger.error("Install with: pip install hindsight-api[mcp]")
@@ -70,11 +69,9 @@ def create_app(
app = FastAPI(title="Hindsight API", version="0.0.7")
logger.info("HTTP REST API disabled")
# Mount MCP server and chain its lifespan if enabled
if mcp_app is not None:
# Get both MCP apps' underlying Starlette apps for lifespan access
multi_bank_starlette_app = mcp_app.multi_bank_app
single_bank_starlette_app = mcp_app.single_bank_app
# Add MCP middleware and chain its lifespan if enabled
if mcp_servers is not None:
multi_bank_server, single_bank_server, multi_bank_starlette_app, single_bank_starlette_app = mcp_servers
# Store the original lifespan
original_lifespan = app.router.lifespan_context
@@ -94,8 +91,19 @@ def create_app(
# Replace the app's lifespan with the chained version
app.router.lifespan_context = chained_lifespan
# Mount the MCP middleware
app.mount(mcp_mount_path, mcp_app)
# Add MCP as a wrapping middleware — intercepts /mcp* requests directly,
# passes everything else through to the FastAPI app. No Starlette Mount
# means no 307 redirect for /mcp (no trailing slash).
app.add_middleware(
MCPMiddleware,
memory=memory,
prefix=mcp_mount_path,
multi_bank_app=multi_bank_starlette_app,
single_bank_app=single_bank_starlette_app,
multi_bank_server=multi_bank_server,
single_bank_server=single_bank_server,
)
logger.info(f"MCP server enabled at {mcp_mount_path}/")
return app
+69 -86
View File
@@ -32,9 +32,44 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
return {}
from typing import Callable
from pydantic import BaseModel, ConfigDict, Field, field_validator
from hindsight_api import MemoryEngine
def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
"""
Field wrapper that ensures default_factory values appear in OpenAPI schema.
Pydantic doesn't include default_factory in OpenAPI schemas, causing OpenAPI
Generator to make fields Optional with default=None instead of non-optional
with the correct default value.
This wrapper adds json_schema_extra to include the default in the schema.
"""
# Determine the default value for the schema based on the factory
if default_factory is list:
schema_default = []
elif default_factory is dict:
schema_default = {}
else:
# For custom factories (like IncludeOptions), use empty dict as placeholder
schema_default = {}
# Add or merge json_schema_extra
json_extra = kwargs.pop("json_schema_extra", {})
if isinstance(json_extra, dict):
json_extra["default"] = schema_default
else:
# If json_schema_extra was a function, we can't merge easily
# Fall back to just setting default
json_extra = {"default": schema_default}
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
@@ -103,8 +138,8 @@ class RecallRequest(BaseModel):
query_timestamp: str | None = Field(
default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')"
)
include: IncludeOptions = Field(
default_factory=IncludeOptions,
include: IncludeOptions = FieldWithDefault(
IncludeOptions,
description="Options for including additional data (entities are included by default)",
)
tags: list[str] | None = Field(
@@ -570,18 +605,16 @@ class ReflectLLMCall(BaseModel):
class ReflectBasedOn(BaseModel):
"""Evidence the response is based on: memories, mental models, and directives."""
memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
mental_models: list[ReflectMentalModel] = Field(
default_factory=list, description="Mental models used during reflection"
)
directives: list[ReflectDirective] = Field(default_factory=list, description="Directives applied during reflection")
memories: list[ReflectFact] = FieldWithDefault(list, description="Memory facts used to generate the response")
mental_models: list[ReflectMentalModel] = FieldWithDefault(list, description="Mental models used during reflection")
directives: list[ReflectDirective] = FieldWithDefault(list, description="Directives applied during reflection")
class ReflectTrace(BaseModel):
"""Execution trace of LLM and tool calls during reflection."""
tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection")
tool_calls: list[ReflectToolCall] = FieldWithDefault(list, description="Tool calls made during reflection")
llm_calls: list[ReflectLLMCall] = FieldWithDefault(list, description="LLM calls made during reflection")
class ReflectResponse(BaseModel):
@@ -942,7 +975,7 @@ class DocumentResponse(BaseModel):
created_at: str
updated_at: str
memory_unit_count: int
tags: list[str] = Field(default_factory=list, description="Tags associated with this document")
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
class DeleteDocumentResponse(BaseModel):
@@ -1066,7 +1099,7 @@ class DirectiveResponse(BaseModel):
content: str
priority: int = 0
is_active: bool = True
tags: list[str] = Field(default_factory=list)
tags: list[str] = FieldWithDefault(list)
created_at: str | None = None
updated_at: str | None = None
@@ -1084,7 +1117,7 @@ class CreateDirectiveRequest(BaseModel):
content: str = Field(description="The directive text to inject into prompts")
priority: int = Field(default=0, description="Higher priority directives are injected first")
is_active: bool = Field(default=True, description="Whether this directive is active")
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
tags: list[str] = FieldWithDefault(list, description="Tags for filtering")
class UpdateDirectiveRequest(BaseModel):
@@ -1121,9 +1154,9 @@ class MentalModelResponse(BaseModel):
content: str = Field(
description="The mental model content as well-formatted markdown (auto-generated from reflect endpoint)"
)
tags: list[str] = Field(default_factory=list)
tags: list[str] = FieldWithDefault(list)
max_tokens: int = Field(default=2048)
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger)
trigger: MentalModelTrigger = FieldWithDefault(MentalModelTrigger)
last_refreshed_at: str | None = None
created_at: str | None = None
reflect_response: dict | None = Field(
@@ -1159,9 +1192,9 @@ class CreateMentalModelRequest(BaseModel):
)
name: str = Field(description="Human-readable name for the mental model")
source_query: str = Field(description="The query to run to generate content")
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
tags: list[str] = FieldWithDefault(list, description="Tags for scoped visibility")
max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger, description="Trigger settings")
trigger: MentalModelTrigger = FieldWithDefault(MentalModelTrigger, description="Trigger settings")
class CreateMentalModelResponse(BaseModel):
@@ -1400,6 +1433,26 @@ def create_app(
app.state.prometheus_reader = None
# Metrics collector is already initialized as no-op by default
# Initialize OpenTelemetry tracing if enabled
if config.otel_traces_enabled:
if not config.otel_exporter_otlp_endpoint:
logging.warning("OTEL tracing enabled but no endpoint configured. Tracing disabled.")
else:
from hindsight_api.tracing import create_span_recorder, initialize_tracing
try:
initialize_tracing(
service_name=config.otel_service_name,
endpoint=config.otel_exporter_otlp_endpoint,
headers=config.otel_exporter_otlp_headers,
deployment_environment=config.otel_deployment_environment,
)
create_span_recorder()
logging.info("OpenTelemetry tracing enabled and configured")
except Exception as e:
logging.error(f"Failed to initialize tracing: {e}")
logging.warning("Continuing without tracing")
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
if initialize_memory:
await memory.initialize()
@@ -2334,23 +2387,6 @@ def _register_routes(app: FastAPI):
):
"""Get a mental model by ID."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelGetContext
ctx = MentalModelGetContext(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
validation = await validator.validate_mental_model_get(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
mental_model = await app.state.memory.get_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
@@ -2359,25 +2395,6 @@ def _register_routes(app: FastAPI):
if mental_model is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
# Post-operation hook
if validator:
from hindsight_api.extensions.operation_validator import MentalModelGetResult
content = mental_model.get("content", "")
output_tokens = len(content) // 4 if content else 0
result_ctx = MentalModelGetResult(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
output_tokens=output_tokens,
success=True,
)
try:
await validator.on_mental_model_get_complete(result_ctx)
except Exception as hook_err:
logger.warning(f"Post-mental-model-get hook error (non-fatal): {hook_err}")
return MentalModelResponse(**mental_model)
except (AuthenticationError, HTTPException):
raise
@@ -2407,23 +2424,6 @@ def _register_routes(app: FastAPI):
):
"""Create a mental model (async - returns operation_id)."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
ctx = MentalModelRefreshContext(
bank_id=bank_id,
mental_model_id=None, # Not yet created
request_context=request_context,
)
validation = await validator.validate_mental_model_refresh(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
# 1. Create the mental model with placeholder content
mental_model = await app.state.memory.create_mental_model(
bank_id=bank_id,
@@ -2471,23 +2471,6 @@ def _register_routes(app: FastAPI):
):
"""Refresh a mental model by re-running its source query (async)."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
ctx = MentalModelRefreshContext(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
validation = await validator.validate_mental_model_refresh(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
+108 -69
View File
@@ -43,6 +43,10 @@ _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
# Context variables for tenant_id and api_key_id (set by authenticate, used by usage metering)
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -54,6 +58,16 @@ def get_current_api_key() -> str | None:
return _current_api_key.get()
def get_current_tenant_id() -> str | None:
"""Get the current tenant_id from context."""
return _current_tenant_id.get()
def get_current_api_key_id() -> str | None:
"""Get the current api_key_id from context."""
return _current_api_key_id.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -73,8 +87,22 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=None if multi_bank else {"retain", "recall", "reflect"}, # Scoped tools for single-bank mode
tools=None
if multi_bank
else {
"retain",
"recall",
"reflect",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}, # Scoped tools for single-bank mode (excludes bank management: list_banks, create_bank)
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
@@ -90,7 +118,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
class MCPMiddleware:
"""ASGI middleware that handles authentication and routes to appropriate MCP server.
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
This middleware wraps the main FastAPI app and intercepts requests matching the
configured prefix (default: /mcp). Non-MCP requests pass through to the inner app.
Authentication:
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
@@ -121,27 +152,33 @@ class MCPMiddleware:
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
"""
def __init__(self, app, memory: MemoryEngine):
def __init__(
self,
app,
memory: MemoryEngine,
prefix: str = "/mcp",
multi_bank_app=None,
single_bank_app=None,
multi_bank_server=None,
single_bank_server=None,
):
self.app = app
self.prefix = prefix
self.memory = memory
self.tenant_extension = memory._tenant_extension
# Create two server instances:
# 1. Multi-bank server (for /mcp/ root endpoint)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
# 2. Single-bank server (for /mcp/{bank_id}/ endpoints)
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/")
# Backward compatibility: expose multi_bank_app as mcp_app
self.mcp_app = self.multi_bank_app
# Expose the lifespan for the parent app to chain (use multi-bank as default)
self.lifespan = (
self.multi_bank_app.lifespan_handler if hasattr(self.multi_bank_app, "lifespan_handler") else None
)
if multi_bank_app and single_bank_app:
# Pre-created servers (used when called via add_middleware from create_app)
self.multi_bank_app = multi_bank_app
self.single_bank_app = single_bank_app
self.multi_bank_server = multi_bank_server
self.single_bank_server = single_bank_server
else:
# Create servers internally (for direct construction / tests)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/")
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
@@ -153,9 +190,20 @@ class MCPMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.multi_bank_app(scope, receive, send)
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Check if this is an MCP request (matches prefix)
if not (path == self.prefix or path.startswith(self.prefix + "/")):
# Not an MCP request — pass through to the inner app
await self.app(scope, receive, send)
return
# Strip prefix from path
path = path[len(self.prefix) :] or "/"
# Extract auth token from header (for tenant auth propagation)
auth_header = self._get_header(scope, "Authorization")
auth_token: str | None = None
@@ -165,6 +213,8 @@ class MCPMiddleware:
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
@@ -178,7 +228,11 @@ class MCPMiddleware:
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
tenant_context = await self.tenant_extension.authenticate_mcp(RequestContext(api_key=auth_token))
auth_context = RequestContext(api_key=auth_token)
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
# Capture tenant_id and api_key_id set by authenticate() for usage metering
auth_tenant_id = auth_context.tenant_id
auth_api_key_id = auth_context.api_key_id
except AuthenticationError as e:
await self._send_error(send, 401, str(e))
return
@@ -188,36 +242,15 @@ class MCPMiddleware:
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
)
path = scope.get("path", "")
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
root_path = scope.get("root_path", "")
if root_path and path.startswith(root_path):
path = path[len(root_path) :] or "/"
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
if path.startswith("/mcp/"):
path = path[4:] # Remove /mcp prefix
elif path == "/mcp":
path = "/"
# Ensure path has leading slash (needed after stripping mount path)
if path and not path.startswith("/"):
path = "/" + path
# Try to get bank_id from header first (for Claude Code compatibility)
bank_id = self._get_header(scope, "X-Bank-Id")
bank_id_from_path = False
# MCP endpoint paths that should not be treated as bank_ids
MCP_ENDPOINTS = {"sse", "messages"}
# If no header, try to extract from path: /{bank_id}/...
new_path = path
if not bank_id and path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
# Don't treat MCP endpoints as bank_ids
if parts[0] and parts[0] not in MCP_ENDPOINTS:
if parts[0]:
# First segment looks like a bank_id
bank_id = parts[0]
bank_id_from_path = True
@@ -233,19 +266,32 @@ class MCPMiddleware:
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id and api_key context
# Set bank_id, api_key, tenant_id, and api_key_id context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
try:
new_scope = scope.copy()
new_scope["path"] = new_path
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
# that might contain the literal string "data: /messages".
is_sse_response = False
async def send_wrapper(message):
if message["type"] == "http.response.body" and bank_id_from_path:
nonlocal is_sse_response
if message["type"] == "http.response.start":
for header_name, header_value in message.get("headers", []):
if header_name == b"content-type" and b"text/event-stream" in header_value:
is_sse_response = True
break
if message["type"] == "http.response.body" and bank_id_from_path and is_sse_response:
body = message.get("body", b"")
if body and b"/messages" in body:
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
@@ -258,6 +304,10 @@ class MCPMiddleware:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
if tenant_id_token is not None:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
if schema_token is not None:
_current_schema.reset(schema_token)
@@ -279,30 +329,19 @@ class MCPMiddleware:
)
def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests with dynamic tool exposure.
def create_mcp_servers(memory: MemoryEngine):
"""Create multi-bank and single-bank MCP servers and their Starlette apps.
Authentication:
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
Two modes based on URL structure:
1. Single-bank mode (recommended for agent isolation):
- URL: /mcp/{bank_id}/
- Tools: retain, recall, reflect (no bank_id parameter)
- Example: claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/
2. Multi-bank mode (for cross-bank operations):
- URL: /mcp/
- Tools: retain, recall, reflect, list_banks, create_bank (all with bank_id parameter)
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var (default: "default")
- Example: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
Args:
memory: MemoryEngine instance
Returns the servers and apps separately so lifespans can be chained before
the middleware wraps the main app.
Returns:
ASGI application
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
"""
return MCPMiddleware(None, memory)
multi_bank_server = create_mcp_server(memory, multi_bank=True)
multi_bank_app = multi_bank_server.http_app(path="/")
single_bank_server = create_mcp_server(memory, multi_bank=False)
single_bank_app = single_bank_server.http_app(path="/")
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
+83 -4
View File
@@ -66,27 +66,40 @@ ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
# Cohere configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
ENV_EMBEDDINGS_COHERE_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL"
ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
# LiteLLM gateway configuration (for embeddings and reranker via LiteLLM proxy)
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
# LiteLLM configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_LITELLM_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE"
ENV_EMBEDDINGS_LITELLM_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY"
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
ENV_RERANKER_LITELLM_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_API_BASE"
ENV_RERANKER_LITELLM_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_API_KEY"
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
# Deprecated: Legacy shared LiteLLM config (for backward compatibility)
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
@@ -108,6 +121,13 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
@@ -183,6 +203,7 @@ DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDING_DIMENSION = 384
@@ -190,6 +211,9 @@ DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
)
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
DEFAULT_RERANKER_MAX_CANDIDATES = 300
@@ -251,6 +275,11 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# OpenTelemetry tracing configuration
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -381,20 +410,32 @@ class HindsightConfig:
embeddings_provider: str
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_local_trust_remote_code: bool
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_api_key: str | None
embeddings_cohere_model: str
embeddings_cohere_base_url: str | None
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
reranker_max_candidates: int
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
reranker_litellm_model: str
# Server
host: str
@@ -447,6 +488,13 @@ class HindsightConfig:
# Reflect agent settings
reflect_max_iterations: int
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
otel_exporter_otlp_endpoint: str | None
otel_exporter_otlp_headers: str | None
otel_service_name: str
otel_deployment_environment: str
def validate(self) -> None:
"""Validate configuration values and raise errors for invalid combinations."""
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
@@ -567,9 +615,21 @@ class HindsightConfig:
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
embeddings_local_trust_remote_code=os.getenv(
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
# Cohere embeddings (with backward-compatible fallback to shared API key)
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
# LiteLLM embeddings (with backward-compatible fallback to shared config)
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
embeddings_litellm_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
embeddings_litellm_model=os.getenv(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
@@ -580,13 +640,25 @@ class HindsightConfig:
reranker_local_max_concurrent=int(
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
),
reranker_local_trust_remote_code=os.getenv(
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
reranker_tei_max_concurrent=int(
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
),
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
# Cohere reranker (with backward-compatible fallback to shared API key)
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
# LiteLLM reranker (with backward-compatible fallback to shared config)
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
reranker_litellm_api_key=os.getenv(ENV_RERANKER_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
reranker_litellm_model=os.getenv(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
@@ -646,6 +718,13 @@ class HindsightConfig:
),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
# OpenTelemetry tracing configuration
otel_traces_enabled=os.getenv(ENV_OTEL_TRACES_ENABLED, str(DEFAULT_OTEL_TRACES_ENABLED)).lower()
in ("true", "1", "yes"),
otel_exporter_otlp_endpoint=os.getenv(ENV_OTEL_EXPORTER_OTLP_ENDPOINT) or None,
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
)
config.validate()
return config
@@ -426,94 +426,109 @@ async def _process_memory(
Returns:
Dict with action summary: created/updated/merged counts
"""
from ...tracing import get_tracer, is_tracing_enabled
fact_text = memory["text"]
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
tags=fact_tags, # Pass source memory's tags for security
)
if perf:
perf.record_timing("recall", time.time() - t0)
# Create parent span for this memory's consolidation
tracer = get_tracer()
if is_tracing_enabled():
consolidation_span = tracer.start_span("hindsight.consolidation")
consolidation_span.set_attribute("hindsight.memory_id", str(memory_id))
consolidation_span.set_attribute("hindsight.bank_id", bank_id)
else:
consolidation_span = None
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
try:
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
tags=fact_tags, # Pass source memory's tags for security
)
if perf:
perf.record_timing("recall", time.time() - t0)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
if len(results) == 1:
return results[0]
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
if len(results) == 1:
return results[0]
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
finally:
if consolidation_span:
consolidation_span.end()
async def _execute_update_action(
@@ -733,22 +748,37 @@ async def _find_related_observations(
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
from ...tracing import get_tracer, is_tracing_enabled
config = get_config()
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
_quiet=True, # Suppress logging
)
# Create span for recall operation within consolidation
tracer = get_tracer()
if is_tracing_enabled():
recall_span = tracer.start_span("hindsight.consolidation_recall")
recall_span.set_attribute("hindsight.bank_id", bank_id)
recall_span.set_attribute("hindsight.query", query[:100]) # Truncate for brevity
recall_span.set_attribute("hindsight.fact_type", "observation")
else:
recall_span = None
try:
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
_quiet=True, # Suppress logging
)
finally:
if recall_span:
recall_span.end()
# If no observations returned, return empty list
if not recall_result.results:
@@ -24,20 +24,18 @@ from ..config import (
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
ENV_COHERE_API_KEY,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_RERANKER_COHERE_BASE_URL,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_LITELLM_MODEL,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_MAX_CONCURRENT,
@@ -102,7 +100,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
_executor: ThreadPoolExecutor | None = None
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
def __init__(
self,
model_name: str | None = None,
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -113,9 +117,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
Default: False (disabled for security)
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -181,6 +189,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
@@ -847,23 +856,27 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
api_key = config.reranker_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
base_url = os.environ.get(ENV_RERANKER_COHERE_BASE_URL) or None
return CohereCrossEncoder(api_key=api_key, model=model, base_url=base_url)
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL)
return LiteLLMCrossEncoder(api_base=api_base, api_key=api_key, model=model)
return LiteLLMCrossEncoder(
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
else:
@@ -21,22 +21,19 @@ from ..config import (
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_COHERE_API_KEY,
ENV_EMBEDDINGS_COHERE_BASE_URL,
ENV_EMBEDDINGS_COHERE_MODEL,
ENV_EMBEDDINGS_LITELLM_MODEL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_TEI_URL,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_LLM_API_KEY,
)
@@ -95,7 +92,7 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
"""
Initialize local SentenceTransformers embeddings.
@@ -104,9 +101,13 @@ class LocalSTEmbeddings(Embeddings):
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
Default: False (disabled for security)
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
self._dimension: int | None = None
@@ -176,6 +177,7 @@ class LocalSTEmbeddings(Embeddings):
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
@@ -741,6 +743,7 @@ def create_embeddings_from_env() -> Embeddings:
return LocalSTEmbeddings(
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
@@ -754,17 +757,20 @@ def create_embeddings_from_env() -> Embeddings:
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
api_key = config.embeddings_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
base_url = os.environ.get(ENV_EMBEDDINGS_COHERE_BASE_URL) or None
return CohereEmbeddings(api_key=api_key, model=model, base_url=base_url)
raise ValueError(f"{ENV_EMBEDDINGS_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
return CohereEmbeddings(
api_key=api_key,
model=config.embeddings_cohere_model,
base_url=config.embeddings_cohere_base_url,
)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL)
return LiteLLMEmbeddings(api_base=api_base, api_key=api_key, model=model)
return LiteLLMEmbeddings(
api_base=config.embeddings_litellm_api_base,
api_key=config.embeddings_litellm_api_key,
model=config.embeddings_litellm_model,
)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
File diff suppressed because it is too large Load Diff
@@ -84,7 +84,7 @@ class AnthropicLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Anthropic connection verified successfully")
@@ -223,6 +223,24 @@ class AnthropicLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.stop_reason if hasattr(response, "stop_reason") else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -397,16 +415,41 @@ class AnthropicLLM(LLMInterface):
# Record metrics
metrics = get_metrics_collector()
duration = time.time() - start_time
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -95,7 +95,7 @@ class ClaudeCodeLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Claude Code connection verified successfully")
@@ -237,6 +237,23 @@ class ClaudeCodeLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -136,6 +136,7 @@ class CodexLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
@@ -261,6 +262,26 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
@@ -504,6 +525,28 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -136,6 +136,7 @@ class GeminiLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"{self.provider.upper()} connection verified successfully")
except Exception as e:
@@ -275,6 +276,29 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
finish_reason = None
if hasattr(response, "candidates") and response.candidates:
if hasattr(response.candidates[0], "finish_reason"):
finish_reason = str(response.candidates[0].finish_reason)
span_recorder = get_span_recorder()
from hindsight_api.tracing import _serialize_for_span
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and input_tokens > 0:
logger.info(
@@ -466,6 +490,30 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -129,6 +129,23 @@ class MockLLM(LLMInterface):
if self._mock_exception is not None:
raise self._mock_exception
# Record trace span (minimal for mock provider)
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content="mock response",
input_tokens=10,
output_tokens=5,
duration=0.001, # Mock calls are instant
finish_reason="stop",
error=None,
)
# Return mock response
if self._mock_response is not None:
result = self._mock_response
@@ -192,20 +209,50 @@ class MockLLM(LLMInterface):
if self._mock_exception is not None:
raise self._mock_exception
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
return LLMToolCallResult(
result = self._mock_response
elif isinstance(self._mock_response, list):
# Allow setting just tool calls as a list
result = LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
return LLMToolCallResult(content="mock response", finish_reason="stop")
# Record span with mock values
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
if result.tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result.content,
input_tokens=10, # Mock value
output_tokens=5, # Mock value
duration=0.1, # Mock value
finish_reason=result.finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return result
async def cleanup(self) -> None:
"""Clean up resources (no-op for mock provider)."""
@@ -130,6 +130,7 @@ class OpenAICompatibleLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Connection verified: {self.provider}/{self.model}")
except Exception as e:
@@ -368,6 +369,24 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.choices[0].finish_reason if response.choices else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
@@ -556,6 +575,30 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -402,7 +402,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -447,7 +447,7 @@ async def run_reflect_agent(
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_agent",
scope="reflect_tool_call",
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
@@ -479,7 +479,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -550,7 +550,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -617,23 +617,30 @@ async def run_reflect_agent(
)
continue
# Process done tool
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
)
# Process done tool - wrap with tool call span
from hindsight_api.tracing import get_tracer
tracer = get_tracer()
span_name = "hindsight.reflect_tool_call"
with tracer.start_as_current_span(span_name) as span:
span.set_attribute("hindsight.scope", "reflect_tool_call")
span.set_attribute("hindsight.operation", "reflect_tool_call")
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
)
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
@@ -842,17 +849,67 @@ async def _execute_tool_with_timing(
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
result = await _execute_tool(
tc.name,
tc.arguments,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
from hindsight_api.tracing import get_tracer
start_time = time.time()
# Create span for tool execution
tracer = get_tracer()
# Normalize tool name for span
normalized_name = _normalize_tool_name(tc.name)
span_name = f"hindsight.reflect_tool_exec.{normalized_name}"
# Calculate timestamps
start_time_ns = time.time_ns()
with tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False,
) as span:
# Set attributes
span.set_attribute("hindsight.tool.name", normalized_name)
span.set_attribute("hindsight.tool.id", tc.id)
span.set_attribute("hindsight.tool.arguments", json.dumps(tc.arguments))
try:
result = await _execute_tool(
tc.name,
tc.arguments,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
# Set success attributes
if isinstance(result, dict) and "error" in result:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, result["error"]))
else:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.OK))
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
# End span with correct timestamp
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
return result, duration_ms
except Exception as e:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
raise
async def _execute_tool(
@@ -802,7 +802,7 @@ Text:
extraction_response_json, call_usage = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="memory_extract_facts",
scope="retain_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=max_retries,
@@ -132,6 +132,10 @@ class RetainResult:
unit_ids: list[list[str]] # List of unit IDs per content item
success: bool = True
error: str | None = None
# Actual LLM token usage (populated by engine when available)
llm_input_tokens: int | None = None
llm_output_tokens: int | None = None
llm_total_tokens: int | None = None
@dataclass
+17
View File
@@ -197,18 +197,30 @@ def main():
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
embeddings_tei_url=config.embeddings_tei_url,
embeddings_openai_base_url=config.embeddings_openai_base_url,
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
embeddings_cohere_model=config.embeddings_cohere_model,
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
embeddings_litellm_model=config.embeddings_litellm_model,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_local_force_cpu=config.reranker_local_force_cpu,
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
reranker_tei_url=config.reranker_tei_url,
reranker_tei_batch_size=config.reranker_tei_batch_size,
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
reranker_max_candidates=config.reranker_max_candidates,
reranker_cohere_api_key=config.reranker_cohere_api_key,
reranker_cohere_model=config.reranker_cohere_model,
reranker_cohere_base_url=config.reranker_cohere_base_url,
reranker_litellm_api_base=config.reranker_litellm_api_base,
reranker_litellm_api_key=config.reranker_litellm_api_key,
reranker_litellm_model=config.reranker_litellm_model,
host=args.host,
port=args.port,
log_level=args.log_level,
@@ -242,6 +254,11 @@ def main():
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
otel_traces_enabled=config.otel_traces_enabled,
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
otel_service_name=config.otel_service_name,
otel_deployment_environment=config.otel_deployment_environment,
)
config.configure_logging()
if not args.daemon:
+608 -5
View File
@@ -35,6 +35,12 @@ class MCPToolsConfig:
# How to resolve API key for tenant auth (optional)
api_key_resolver: Callable[[], str | None] | None = None
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
tenant_id_resolver: Callable[[], str | None] | None = None
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
api_key_id_resolver: Callable[[], str | None] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
@@ -50,13 +56,15 @@ class MCPToolsConfig:
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
"""Create RequestContext with API key from resolver if available.
"""Create RequestContext with auth details from resolvers.
This enables tenant auth to work with MCP tools by propagating
the Bearer token from the MCP middleware to the memory engine.
This enables tenant auth and usage metering to work with MCP tools by propagating
the authentication results from the MCP middleware to the memory engine.
"""
api_key = config.api_key_resolver() if config.api_key_resolver else None
return RequestContext(api_key=api_key)
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
def parse_timestamp(timestamp: str) -> datetime | None:
@@ -119,7 +127,19 @@ def register_mcp_tools(
memory: MemoryEngine instance
config: Tool configuration
"""
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
tools_to_register = config.tools or {
"retain",
"recall",
"reflect",
"list_banks",
"create_bank",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}
if "retain" in tools_to_register:
_register_retain(mcp, memory, config)
@@ -136,6 +156,25 @@ def register_mcp_tools(
if "create_bank" in tools_to_register:
_register_create_bank(mcp, memory, config)
# Mental model tools
if "list_mental_models" in tools_to_register:
_register_list_mental_models(mcp, memory, config)
if "get_mental_model" in tools_to_register:
_register_get_mental_model(mcp, memory, config)
if "create_mental_model" in tools_to_register:
_register_create_mental_model(mcp, memory, config)
if "update_mental_model" in tools_to_register:
_register_update_mental_model(mcp, memory, config)
if "delete_mental_model" in tools_to_register:
_register_delete_mental_model(mcp, memory, config)
if "refresh_mental_model" in tools_to_register:
_register_refresh_mental_model(mcp, memory, config)
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the retain tool."""
@@ -511,3 +550,567 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
def _validate_mental_model_inputs(
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
) -> str | None:
"""Validate mental model inputs, returning an error message or None if valid."""
if name is not None and not name.strip():
return "name cannot be empty"
if source_query is not None and not source_query.strip():
return "source_query cannot be empty"
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
return None
# =========================================================================
# MENTAL MODEL TOOLS
# =========================================================================
def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_mental_models tool."""
if config.include_bank_id_param:
@mcp.tool()
async def list_mental_models(
tags: list[str] | None = None,
bank_id: str | None = None,
) -> str:
"""
List mental models (pinned reflections) for a memory bank.
Mental models are living documents that stay current by periodically re-running
a source query through reflect. Use them to maintain up-to-date summaries,
preferences, or synthesized knowledge.
Args:
tags: Optional tags to filter by (returns models matching any tag)
bank_id: Optional bank to list from (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured", "items": []}'
models = await memory.list_mental_models(
bank_id=target_bank,
tags=tags,
request_context=_get_request_context(config),
)
return json.dumps({"items": models}, indent=2, default=str)
except Exception as e:
logger.error(f"Error listing mental models: {e}", exc_info=True)
return f'{{"error": "{e}", "items": []}}'
else:
@mcp.tool()
async def list_mental_models(
tags: list[str] | None = None,
) -> dict:
"""
List mental models (pinned reflections) for this memory bank.
Mental models are living documents that stay current by periodically re-running
a source query through reflect. Use them to maintain up-to-date summaries,
preferences, or synthesized knowledge.
Args:
tags: Optional tags to filter by (returns models matching any tag)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "items": []}
models = await memory.list_mental_models(
bank_id=target_bank,
tags=tags,
request_context=_get_request_context(config),
)
return {"items": models}
except Exception as e:
logger.error(f"Error listing mental models: {e}", exc_info=True)
return {"error": str(e), "items": []}
def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def get_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Get a specific mental model by ID.
Returns the full mental model including its generated content, source query,
and metadata. Use list_mental_models first to discover available model IDs.
Args:
mental_model_id: The ID of the mental model to retrieve
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
model = await memory.get_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if model is None:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps(model, indent=2, default=str)
except Exception as e:
logger.error(f"Error getting mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def get_mental_model(
mental_model_id: str,
) -> dict:
"""
Get a specific mental model by ID.
Returns the full mental model including its generated content, source query,
and metadata. Use list_mental_models first to discover available model IDs.
Args:
mental_model_id: The ID of the mental model to retrieve
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
model = await memory.get_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if model is None:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return model
except Exception as e:
logger.error(f"Error getting mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def create_mental_model(
name: str,
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int = 2048,
bank_id: str | None = None,
) -> str:
"""
Create a new mental model (pinned reflection).
A mental model is a living document generated by running the source_query through
reflect. The content is auto-generated asynchronously - use the returned operation_id
to track progress.
EXAMPLES:
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
- name="Communication Style", source_query="How does the user prefer to communicate?"
Args:
name: Human-readable name for the mental model
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return json.dumps({"error": validation_error})
request_context = _get_request_context(config)
# Create with placeholder content
model = await memory.create_mental_model(
bank_id=target_bank,
name=name,
source_query=source_query,
content="Generating content...",
mental_model_id=mental_model_id,
tags=tags,
max_tokens=max_tokens,
request_context=request_context,
)
# Schedule async refresh to generate actual content
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=model["id"],
request_context=request_context,
)
return json.dumps(
{
"mental_model_id": model["id"],
"operation_id": result["operation_id"],
"status": "created",
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
}
)
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error creating mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def create_mental_model(
name: str,
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int = 2048,
) -> dict:
"""
Create a new mental model (pinned reflection).
A mental model is a living document generated by running the source_query through
reflect. The content is auto-generated asynchronously - use the returned operation_id
to track progress.
EXAMPLES:
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
- name="Communication Style", source_query="How does the user prefer to communicate?"
Args:
name: Human-readable name for the mental model
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return {"error": validation_error}
request_context = _get_request_context(config)
model = await memory.create_mental_model(
bank_id=target_bank,
name=name,
source_query=source_query,
content="Generating content...",
mental_model_id=mental_model_id,
tags=tags,
max_tokens=max_tokens,
request_context=request_context,
)
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=model["id"],
request_context=request_context,
)
return {
"mental_model_id": model["id"],
"operation_id": result["operation_id"],
"status": "created",
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error creating mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the update_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
max_tokens: int | None = None,
tags: list[str] | None = None,
bank_id: str | None = None,
) -> str:
"""
Update a mental model's metadata.
Changes the name, source query, or tags of an existing mental model.
To regenerate the content, use refresh_mental_model after updating the source query.
Args:
mental_model_id: The ID of the mental model to update
name: New name (leave None to keep current)
source_query: New source query (leave None to keep current)
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
tags: New tags (leave None to keep current)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return json.dumps({"error": validation_error})
model = await memory.update_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
name=name,
source_query=source_query,
max_tokens=max_tokens,
tags=tags,
request_context=_get_request_context(config),
)
if model is None:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps(model, indent=2, default=str)
except Exception as e:
logger.error(f"Error updating mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
max_tokens: int | None = None,
tags: list[str] | None = None,
) -> dict:
"""
Update a mental model's metadata.
Changes the name, source query, or tags of an existing mental model.
To regenerate the content, use refresh_mental_model after updating the source query.
Args:
mental_model_id: The ID of the mental model to update
name: New name (leave None to keep current)
source_query: New source query (leave None to keep current)
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
tags: New tags (leave None to keep current)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return {"error": validation_error}
model = await memory.update_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
name=name,
source_query=source_query,
max_tokens=max_tokens,
tags=tags,
request_context=_get_request_context(config),
)
if model is None:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return model
except Exception as e:
logger.error(f"Error updating mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the delete_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def delete_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Delete a mental model.
Permanently removes a mental model and its generated content.
Args:
mental_model_id: The ID of the mental model to delete
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
deleted = await memory.delete_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if not deleted:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps({"status": "deleted", "mental_model_id": mental_model_id})
except Exception as e:
logger.error(f"Error deleting mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def delete_mental_model(
mental_model_id: str,
) -> dict:
"""
Delete a mental model.
Permanently removes a mental model and its generated content.
Args:
mental_model_id: The ID of the mental model to delete
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
deleted = await memory.delete_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if not deleted:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return {"status": "deleted", "mental_model_id": mental_model_id}
except Exception as e:
logger.error(f"Error deleting mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the refresh_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def refresh_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Refresh a mental model by re-running its source query.
Schedules an async task to re-run the source query through reflect and update the
mental model's content with fresh results. Use this after adding new memories or
when the mental model's content may be stale.
Args:
mental_model_id: The ID of the mental model to refresh
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
return json.dumps(
{
"operation_id": result["operation_id"],
"status": "queued",
"message": f"Refresh queued for mental model '{mental_model_id}'.",
}
)
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def refresh_mental_model(
mental_model_id: str,
) -> dict:
"""
Refresh a mental model by re-running its source query.
Schedules an async task to re-run the source query through reflect and update the
mental model's content with fresh results. Use this after adding new memories or
when the mental model's content may be stale.
Args:
mental_model_id: The ID of the mental model to refresh
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
return {
"operation_id": result["operation_id"],
"status": "queued",
"message": f"Refresh queued for mental model '{mental_model_id}'.",
}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
return {"error": str(e)}
+2 -1
View File
@@ -20,7 +20,8 @@ class RequestContext:
api_key: str | None = None
api_key_id: str | None = None # UUID of the API key used for authentication
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
internal: bool = False # True for background/internal operations (not user-visible)
internal: bool = False # True for background/internal operations (skips extension auth)
user_initiated: bool = False # True for async operations that originated from a user request
from pgvector.sqlalchemy import Vector
+480
View File
@@ -0,0 +1,480 @@
"""
OpenTelemetry distributed tracing instrumentation for Hindsight API.
This module provides tracing for:
- LLM API calls with full prompts/completions following GenAI semantic conventions
- Token usage and model information
- Error tracking and finish reasons
Tracing is conditional and disabled by default. When enabled, traces are exported
to Langfuse (or any OTLP-compatible backend) via OTLP HTTP protocol.
"""
import json
import logging
import time
from typing import Any, Optional
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode
logger = logging.getLogger(__name__)
def _serialize_for_span(obj: Any) -> str:
"""Serialize an object for span recording, handling Pydantic models."""
if isinstance(obj, str):
return obj
if hasattr(obj, "model_dump_json"):
# Pydantic v2 model
return obj.model_dump_json()
if hasattr(obj, "json"):
# Pydantic v1 model
return obj.json()
if hasattr(obj, "model_dump"):
# Pydantic v2 model - convert to dict then json
return json.dumps(obj.model_dump())
if hasattr(obj, "dict"):
# Pydantic v1 model - convert to dict then json
return json.dumps(obj.dict())
# Fallback to json.dumps for dicts and other types
return json.dumps(obj)
# No-op tracer for when tracing is disabled
class NoOpTracer:
"""No-op tracer that provides the same interface as OpenTelemetry Tracer but does nothing."""
def start_as_current_span(self, name: str, **kwargs):
"""Return a no-op context manager that yields a NoOpSpan."""
from contextlib import contextmanager
@contextmanager
def noop_span_context():
yield NoOpSpan()
return noop_span_context()
def start_span(self, name: str, **kwargs):
"""Return a no-op span."""
return NoOpSpan()
class NoOpSpan:
"""No-op span that provides the same interface as OpenTelemetry Span but does nothing."""
def set_attribute(self, key: str, value: Any) -> None:
"""No-op."""
pass
def set_status(self, status: Any) -> None:
"""No-op."""
pass
def record_exception(self, exception: Exception) -> None:
"""No-op."""
pass
def add_event(self, name: str, attributes: dict | None = None) -> None:
"""No-op."""
pass
def end(self, end_time: int | None = None) -> None:
"""No-op."""
pass
# Global tracer instance
_tracer: trace.Tracer | NoOpTracer = NoOpTracer()
_tracing_enabled: bool = False
# GenAI semantic convention attribute names (based on v1.37 spec)
class GenAIAttributes:
"""GenAI semantic convention attribute names."""
# Operation and provider
OPERATION_NAME = "gen_ai.operation.name"
PROVIDER_NAME = "gen_ai.provider.name"
# Model information
REQUEST_MODEL = "gen_ai.request.model"
RESPONSE_MODEL = "gen_ai.response.model"
# Token usage
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
# Messages and prompts
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
INPUT_MESSAGES = "gen_ai.input.messages"
OUTPUT_MESSAGES = "gen_ai.output.messages"
# Response metadata
FINISH_REASONS = "gen_ai.response.finish_reasons"
# Error tracking
ERROR_TYPE = "error.type"
# Provider name mapping (Hindsight internal -> GenAI semantic convention)
PROVIDER_NAME_MAPPING = {
"openai": "openai",
"anthropic": "anthropic",
"gemini": "google",
"vertexai": "google",
"groq": "groq",
"ollama": "ollama",
"lmstudio": "lmstudio",
"openai-codex": "openai",
"claude-code": "anthropic",
"mock": "mock",
}
def initialize_tracing(
service_name: str,
endpoint: str,
headers: Optional[str] = None,
deployment_environment: str = "development",
) -> None:
"""
Initialize OpenTelemetry tracing with OTLP exporter.
Args:
service_name: Name of the service for resource attributes
endpoint: OTLP endpoint URL (e.g., https://cloud.langfuse.com/api/public/otel)
headers: Optional headers in format "key1=value1,key2=value2"
deployment_environment: Deployment environment (e.g., development, staging, production)
"""
global _tracer, _tracing_enabled
# Create resource with service information
resource = Resource.create(
{
"service.name": service_name,
"service.version": "0.4.8", # Could import from __version__
"deployment.environment.name": deployment_environment,
}
)
# Parse headers
headers_dict = {}
if headers:
for pair in headers.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
headers_dict[key.strip()] = value.strip()
# Create OTLP HTTP exporter
# Note: Langfuse expects /v1/traces path appended to base endpoint
otlp_endpoint = endpoint if endpoint.endswith("/v1/traces") else f"{endpoint}/v1/traces"
otlp_exporter = OTLPSpanExporter(
endpoint=otlp_endpoint,
headers=headers_dict,
)
# Create tracer provider with batch processor
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Set global tracer provider
trace.set_tracer_provider(provider)
# Get tracer for this application
_tracer = trace.get_tracer(__name__)
_tracing_enabled = True
logger.info(f"Tracing initialized: endpoint={otlp_endpoint}, service={service_name}")
def get_tracer() -> trace.Tracer | NoOpTracer:
"""
Get the global tracer instance.
Returns a no-op tracer if tracing is disabled, so callers don't need to check for None.
This improves code readability by allowing direct use without null checks.
"""
return _tracer
def create_operation_span(operation: str, bank_id: str | None = None):
"""
Create a parent span for a Hindsight operation (retain, reflect, consolidation, etc.).
This creates the span hierarchy:
- hindsight.{operation} (parent)
- chat {model} (child LLM calls)
Args:
operation: Operation name (retain, reflect, consolidation, mental_model_refresh)
bank_id: Optional bank ID for context
Returns:
Span context manager
"""
if not _tracing_enabled or _tracer is None:
# Return a no-op context manager
from contextlib import nullcontext
return nullcontext()
span_name = f"hindsight.{operation}"
span = _tracer.start_as_current_span(span_name)
# Add operation-specific attributes
if span and hasattr(span, "set_attribute"):
span.set_attribute("hindsight.operation", operation)
if bank_id:
span.set_attribute("hindsight.bank_id", bank_id)
return span
def is_tracing_enabled() -> bool:
"""Check if tracing is enabled."""
return _tracing_enabled
# Maximum content length before truncation (to stay within span size limits)
MAX_CONTENT_LENGTH = 100_000 # characters
def _truncate_content(content: str) -> str:
"""Truncate content if too large for span."""
if len(content) > MAX_CONTENT_LENGTH:
return content[:MAX_CONTENT_LENGTH] + f"\n\n[TRUNCATED: {len(content) - MAX_CONTENT_LENGTH} chars omitted]"
return content
class LLMSpanRecorder:
"""
Records OpenTelemetry spans for LLM calls following GenAI semantic conventions.
"""
def __init__(self, tracer: trace.Tracer):
self.tracer = tracer
def record_llm_call(
self,
provider: str,
model: str,
scope: str,
messages: list[dict[str, str]],
response_content: Optional[str],
input_tokens: int,
output_tokens: int,
duration: float,
finish_reason: Optional[str] = None,
error: Optional[Exception] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
) -> None:
"""
Record a completed LLM call as a span with GenAI semantic conventions.
This creates a span AFTER the call completes, using timestamps to
set the correct start/end times. This approach works better with
the existing sync metrics recording pattern.
Args:
provider: Hindsight provider name
model: Model name
scope: Scope identifier (memory, reflect, consolidation, etc.)
messages: Input messages (chat history)
response_content: Response text from LLM
input_tokens: Input token count
output_tokens: Output token count
duration: Call duration in seconds
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
error: Exception if call failed
tool_calls: List of tool calls made (for function calling)
"""
try:
# Map provider name to GenAI semantic convention
genai_provider = PROVIDER_NAME_MAPPING.get(provider.lower(), provider.lower())
# Determine operation name based on scope/context
operation_name = "chat" # Default for GenAI semantic conventions
# Create span name: "hindsight.{scope}" for consistency with parent spans
# Model info is available in span attributes (gen_ai.request.model)
if scope:
span_name = f"hindsight.{scope}"
else:
# Fallback to chat {model} if no scope provided
span_name = f"{operation_name} {model}"
# Calculate timestamps
end_time_ns = time.time_ns()
start_time_ns = end_time_ns - int(duration * 1_000_000_000)
# Create span with explicit timestamps
with self.tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False, # We'll set end time manually
) as span:
# Set required attributes
span.set_attribute(GenAIAttributes.OPERATION_NAME, operation_name)
span.set_attribute(GenAIAttributes.PROVIDER_NAME, genai_provider)
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
# Add custom attributes for Hindsight context
span.set_attribute("hindsight.scope", scope)
span.set_attribute("hindsight.provider.internal", provider)
# Add tool call information if present
if tool_calls:
span.set_attribute("gen_ai.tool_calls.count", len(tool_calls))
# Add tool names as comma-separated list
tool_names = [tc.get("name", "") for tc in tool_calls]
span.set_attribute("gen_ai.tool_calls.names", ",".join(tool_names))
# Format messages for GenAI conventions (as JSON)
input_messages_json = self._format_messages(messages)
output_messages_json = self._format_output(response_content, finish_reason)
# Extract system instructions if present
system_instructions = self._extract_system_instructions(messages)
# Add event with prompts/completions following v1.37 conventions
event_attrs = {}
if input_messages_json:
event_attrs[GenAIAttributes.INPUT_MESSAGES] = input_messages_json
if output_messages_json:
event_attrs[GenAIAttributes.OUTPUT_MESSAGES] = output_messages_json
if system_instructions:
event_attrs[GenAIAttributes.SYSTEM_INSTRUCTIONS] = system_instructions
if finish_reason:
event_attrs[GenAIAttributes.FINISH_REASONS] = json.dumps([finish_reason])
span.add_event(
"gen_ai.client.inference.operation.details",
attributes=event_attrs,
)
# Add individual tool call events with details
if tool_calls:
for i, tc in enumerate(tool_calls):
tool_event_attrs = {
"tool.name": tc.get("name", ""),
"tool.id": tc.get("id", ""),
"tool.arguments": json.dumps(tc.get("arguments", {})),
}
span.add_event(f"gen_ai.tool_call.{i}", attributes=tool_event_attrs)
# Handle errors
if error:
span.set_status(Status(StatusCode.ERROR, str(error)))
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
span.record_exception(error)
else:
span.set_status(Status(StatusCode.OK))
# Set end time
span.end(end_time=end_time_ns)
except Exception as e:
# Don't let tracing errors break LLM calls
logger.error(f"Failed to record LLM span: {e}", exc_info=True)
def _format_messages(self, messages: list[dict[str, str]]) -> str:
"""
Format messages into GenAI semantic convention format (JSON array).
Returns JSON string representation of message array.
"""
try:
formatted = []
for msg in messages:
content = msg.get("content", "")
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
formatted.append(
{
"role": msg.get("role", "user"),
"content": content,
}
)
return json.dumps(formatted)
except Exception as e:
logger.warning(f"Failed to format input messages: {e}")
return "[]"
def _format_output(
self,
content: Optional[str],
finish_reason: Optional[str],
) -> str:
"""Format output message into GenAI semantic convention format."""
try:
if content is None:
return "[]"
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
return json.dumps(
[
{
"role": "assistant",
"content": content,
}
]
)
except Exception as e:
logger.warning(f"Failed to format output message: {e}")
return "[]"
def _extract_system_instructions(self, messages: list[dict[str, str]]) -> Optional[str]:
"""Extract system instructions from messages if present."""
try:
for msg in messages:
if msg.get("role") == "system":
content = msg.get("content", "")
if isinstance(content, str):
return _truncate_content(content)
return str(content)
except Exception as e:
logger.warning(f"Failed to extract system instructions: {e}")
return None
class NoOpLLMSpanRecorder:
"""No-op span recorder for when tracing is disabled."""
def record_llm_call(self, **kwargs) -> None:
"""No-op."""
pass
# Global span recorder instance
_span_recorder: Optional[LLMSpanRecorder] = None
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
"""Get the global span recorder (NoOp if tracing disabled)."""
if _span_recorder is None:
return NoOpLLMSpanRecorder()
return _span_recorder
def create_span_recorder() -> LLMSpanRecorder:
"""Create and set the global span recorder."""
global _span_recorder
tracer = get_tracer()
if tracer is None:
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
_span_recorder = LLMSpanRecorder(tracer)
return _span_recorder
+2
View File
@@ -33,6 +33,8 @@ dependencies = [
"opentelemetry-sdk>=1.20.0",
"opentelemetry-instrumentation-fastapi>=0.41b0",
"opentelemetry-exporter-prometheus>=0.41b0",
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
"opentelemetry-semantic-conventions>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"google-auth>=2.0.0",
+8
View File
@@ -353,6 +353,14 @@ class TestOperationHooksParameters:
assert post_result.error is None
assert post_result.unit_ids == result # Should match the return value
# Verify actual LLM token usage is populated
assert post_result.llm_input_tokens is not None
assert post_result.llm_input_tokens > 0
assert post_result.llm_output_tokens is not None
assert post_result.llm_output_tokens > 0
assert post_result.llm_total_tokens is not None
assert post_result.llm_total_tokens == post_result.llm_input_tokens + post_result.llm_output_tokens
@pytest.mark.asyncio
async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
"""Pre-recall hook receives all user-provided parameters."""
@@ -1,6 +1,7 @@
"""Integration test for MCP endpoint routing.
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets.
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
and that URLs with or without trailing slashes both work (no 307 redirect).
"""
import httpx
@@ -39,12 +40,18 @@ async def test_mcp_endpoint_routing_integration(memory):
multi_tools = {t.name for t in multi_result.tools}
# Multi-bank should have all tools including bank management
# Multi-bank should have all tools including bank management and mental models
assert "retain" in multi_tools
assert "recall" in multi_tools
assert "reflect" in multi_tools
assert "list_banks" in multi_tools, "Multi-bank should expose list_banks"
assert "create_bank" in multi_tools, "Multi-bank should expose create_bank"
assert "list_mental_models" in multi_tools, "Multi-bank should expose list_mental_models"
assert "create_mental_model" in multi_tools, "Multi-bank should expose create_mental_model"
assert "get_mental_model" in multi_tools, "Multi-bank should expose get_mental_model"
assert "update_mental_model" in multi_tools, "Multi-bank should expose update_mental_model"
assert "delete_mental_model" in multi_tools, "Multi-bank should expose delete_mental_model"
assert "refresh_mental_model" in multi_tools, "Multi-bank should expose refresh_mental_model"
# Multi-bank retain should have bank_id parameter
retain_tool = next((t for t in multi_result.tools if t.name == "retain"), None)
@@ -64,10 +71,12 @@ async def test_mcp_endpoint_routing_integration(memory):
single_tools = {t.name for t in single_result.tools}
# Single-bank should only have scoped tools (no bank management)
# Single-bank should have scoped tools including mental models (no bank management)
assert "retain" in single_tools
assert "recall" in single_tools
assert "reflect" in single_tools
assert "list_mental_models" in single_tools, "Single-bank should expose list_mental_models"
assert "create_mental_model" in single_tools, "Single-bank should expose create_mental_model"
assert "list_banks" not in single_tools, "Single-bank should NOT expose list_banks"
assert "create_bank" not in single_tools, "Single-bank should NOT expose create_bank"
@@ -76,3 +85,196 @@ async def test_mcp_endpoint_routing_integration(memory):
assert retain_tool is not None
single_params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" not in single_params, "Single-bank retain should NOT have bank_id parameter"
@pytest.mark.asyncio
async def test_mcp_no_trailing_slash_works(memory):
"""Test that /mcp (no trailing slash) discovers tools without 307 redirect.
Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary Redirect.
Many MCP clients don't follow POST redirects, causing 0 tools to be discovered.
MCPMiddleware wraps the app directly (no Mount), so the redirect never happens.
"""
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
from httpx import ASGITransport
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# /mcp (no slash) should work the same as /mcp/
async with streamable_http_client("http://test/mcp", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert len(tools) >= 11, f"Expected at least 11 tools from /mcp, got {len(tools)}: {tools}"
assert "retain" in tools
assert "recall" in tools
assert "list_banks" in tools
# /mcp/my-bank (single-bank, no slash) should also work
async with streamable_http_client("http://test/mcp/my-bank", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert "retain" in tools
assert "list_banks" not in tools, "Single-bank /mcp/my-bank should NOT expose list_banks"
@pytest.mark.asyncio
async def test_mcp_tool_execution_through_client(memory):
"""Test that tools can be called (not just discovered) through the MCP client.
This verifies the full pipeline: HTTP → middleware → FastMCP → tool → engine → response.
Previous tests only checked tool discovery (list_tools), not actual execution.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Execute list_banks tool
result = await session.call_tool("list_banks", arguments={})
assert result is not None
assert len(result.content) > 0
# The result text should be valid JSON with a "banks" key
import json
response_text = result.content[0].text
parsed = json.loads(response_text)
assert "banks" in parsed
@pytest.mark.asyncio
async def test_mcp_mental_model_validation_through_client(memory):
"""Test that input validation works through the real MCP transport.
Verifies that invalid inputs return error messages without crashing,
and that the engine is never called with invalid data.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# Test: empty name should return validation error
import json
result = await session.call_tool(
"create_mental_model",
arguments={"name": "", "source_query": "test query"},
)
assert result is not None
parsed = json.loads(result.content[0].text)
assert "error" in parsed
assert "name cannot be empty" in parsed["error"]
# Test: max_tokens out of range should return validation error
result = await session.call_tool(
"create_mental_model",
arguments={"name": "Test", "source_query": "test query", "max_tokens": 0},
)
parsed = json.loads(result.content[0].text)
assert "error" in parsed
assert "max_tokens must be between 256 and 8192" in parsed["error"]
@pytest.mark.asyncio
async def test_mcp_bank_named_sse_routes_to_single_bank(memory):
"""Test that a bank named 'sse' routes to single-bank mode.
Regression test: the old MCP_ENDPOINTS blocklist prevented banks named 'sse'
or 'messages' from being accessed via path routing. They fell through to
multi-bank mode instead.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/sse/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
# Should be single-bank mode (no bank management tools)
assert "retain" in tools
assert "recall" in tools
assert "list_banks" not in tools, "Bank 'sse' should route to single-bank mode"
assert "create_bank" not in tools
# retain should NOT have bank_id parameter (single-bank mode)
retain_tool = next(t for t in result.tools if t.name == "retain")
params = set(retain_tool.inputSchema.get("properties", {}).keys())
assert "bank_id" not in params
@pytest.mark.asyncio
async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
"""Test that a bank named 'messages' routes to single-bank mode.
Same regression test as test_mcp_bank_named_sse_routes_to_single_bank but for 'messages'.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
async with streamable_http_client("http://test/mcp/messages/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
tools = {t.name for t in result.tools}
assert "retain" in tools
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
+2 -2
View File
@@ -165,5 +165,5 @@ class TestMCPExtensionIntegration:
assert "create_bank" in tools
# Extension tool also present
assert "test_extension_tool" in tools
# Total: 5 core + 1 extension = 6 tools
assert len(tools) == 6
# At least 11 core + 1 extension = 12 tools (may grow as new tools are added)
assert len(tools) >= 12
+146 -46
View File
@@ -1,8 +1,9 @@
"""Test MCP server routing with dynamic bank_id."""
import pytest
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_memory():
@@ -17,7 +18,7 @@ def mock_memory():
@pytest.mark.asyncio
async def test_mcp_context_variable():
"""Test that context variable works correctly."""
from hindsight_api.api.mcp import get_current_bank_id, _current_bank_id
from hindsight_api.api.mcp import _current_bank_id, get_current_bank_id
# Initially None
assert get_current_bank_id() is None
@@ -36,7 +37,7 @@ async def test_mcp_context_variable():
@pytest.mark.asyncio
async def test_mcp_tools_use_context_bank_id(mock_memory):
"""Test that MCP tools use bank_id from context."""
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id
from hindsight_api.api.mcp import _current_bank_id, create_mcp_server
mcp_server = create_mcp_server(mock_memory)
@@ -62,6 +63,7 @@ async def test_mcp_tools_use_context_bank_id(mock_memory):
def test_path_parsing_logic():
"""Test the path parsing logic for bank_id extraction."""
def parse_path(path):
"""Simulate the path parsing logic from MCPMiddleware."""
if not path.startswith("/") or len(path) <= 1:
@@ -102,7 +104,7 @@ def test_path_parsing_logic():
@pytest.mark.asyncio
async def test_api_key_context_variable():
"""Test that API key context variable works correctly."""
from hindsight_api.api.mcp import get_current_api_key, _current_api_key
from hindsight_api.api.mcp import _current_api_key, get_current_api_key
# Initially None
assert get_current_api_key() is None
@@ -121,7 +123,7 @@ async def test_api_key_context_variable():
@pytest.mark.asyncio
async def test_mcp_tools_propagate_api_key(mock_memory):
"""Test that MCP tools propagate API key to RequestContext."""
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id, _current_api_key
from hindsight_api.api.mcp import _current_api_key, _current_bank_id, create_mcp_server
mcp_server = create_mcp_server(mock_memory)
tools = mcp_server._tool_manager._tools
@@ -143,21 +145,99 @@ async def test_mcp_tools_propagate_api_key(mock_memory):
_current_api_key.reset(api_key_token)
@pytest.mark.asyncio
async def test_tenant_id_context_variable():
"""Test that tenant_id and api_key_id context variables work correctly."""
from hindsight_api.api.mcp import (
_current_api_key_id,
_current_tenant_id,
get_current_api_key_id,
get_current_tenant_id,
)
# Initially None
assert get_current_tenant_id() is None
assert get_current_api_key_id() is None
# Set and verify
tenant_token = _current_tenant_id.set("org-123")
key_id_token = _current_api_key_id.set("key-456")
try:
assert get_current_tenant_id() == "org-123"
assert get_current_api_key_id() == "key-456"
finally:
_current_tenant_id.reset(tenant_token)
_current_api_key_id.reset(key_id_token)
# Back to None after reset
assert get_current_tenant_id() is None
assert get_current_api_key_id() is None
@pytest.mark.asyncio
async def test_mcp_tools_propagate_tenant_id_and_api_key_id(mock_memory):
"""Test that MCP tools propagate tenant_id and api_key_id to RequestContext.
This is the critical test for usage metering: the UsageMeteringValidator reads
request_context.tenant_id to identify the org for billing. Without this,
MCP operations get tenant_id="unknown" and billing is skipped entirely.
"""
from hindsight_api.api.mcp import (
_current_api_key,
_current_api_key_id,
_current_bank_id,
_current_tenant_id,
create_mcp_server,
)
mcp_server = create_mcp_server(mock_memory)
tools = mcp_server._tool_manager._tools
# Set all context vars (simulating what MCPMiddleware does after authenticate_mcp)
bank_token = _current_bank_id.set("test-bank")
api_key_token = _current_api_key.set("hsk_test_key")
tenant_token = _current_tenant_id.set("org-billing-123")
key_id_token = _current_api_key_id.set("key-uuid-456")
try:
retain_tool = tools["retain"]
await retain_tool.fn(content="test content", context="test_context", async_processing=False)
# Verify the RequestContext passed to memory engine has all auth fields
mock_memory.retain_batch_async.assert_called_once()
request_context = mock_memory.retain_batch_async.call_args.kwargs["request_context"]
assert request_context.api_key == "hsk_test_key"
assert request_context.tenant_id == "org-billing-123"
assert request_context.api_key_id == "key-uuid-456"
finally:
_current_bank_id.reset(bank_token)
_current_api_key.reset(api_key_token)
_current_tenant_id.reset(tenant_token)
_current_api_key_id.reset(key_id_token)
def test_multi_bank_mode_exposes_all_tools(mock_memory):
"""Test that multi-bank mode exposes all tools including bank management."""
"""Test that multi-bank mode exposes all tools including bank management and mental models."""
from hindsight_api.api.mcp import create_mcp_server
# Create server in multi-bank mode (default)
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
# Should have all tools
# Core tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
assert "list_banks" in tools
assert "create_bank" in tools
# Mental model tools
assert "list_mental_models" in tools
assert "get_mental_model" in tools
assert "create_mental_model" in tools
assert "update_mental_model" in tools
assert "delete_mental_model" in tools
assert "refresh_mental_model" in tools
def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
"""Test that single-bank mode only exposes bank-scoped tools."""
@@ -167,11 +247,19 @@ def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
tools = mcp_server._tool_manager._tools
# Should only have bank-scoped tools
# Should have bank-scoped tools
assert "retain" in tools
assert "recall" in tools
assert "reflect" in tools
# Mental model tools should also be present (they're bank-scoped)
assert "list_mental_models" in tools
assert "get_mental_model" in tools
assert "create_mental_model" in tools
assert "update_mental_model" in tools
assert "delete_mental_model" in tools
assert "refresh_mental_model" in tools
# Should NOT have bank management tools
assert "list_banks" not in tools
assert "create_bank" not in tools
@@ -179,46 +267,56 @@ def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
def test_multi_bank_mode_tools_have_bank_id_param(mock_memory):
"""Test that multi-bank mode tools include bank_id parameter."""
from hindsight_api.api.mcp import create_mcp_server
import inspect
from hindsight_api.api.mcp import create_mcp_server
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
tools = mcp_server._tool_manager._tools
# Check that tools have bank_id parameter
retain_tool = tools["retain"]
retain_sig = inspect.signature(retain_tool.fn)
assert "bank_id" in retain_sig.parameters
recall_tool = tools["recall"]
recall_sig = inspect.signature(recall_tool.fn)
assert "bank_id" in recall_sig.parameters
reflect_tool = tools["reflect"]
reflect_sig = inspect.signature(reflect_tool.fn)
assert "bank_id" in reflect_sig.parameters
# All bank-scoped tools should have bank_id parameter in multi-bank mode
bank_scoped_tools = [
"retain",
"recall",
"reflect",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
]
for tool_name in bank_scoped_tools:
tool = tools[tool_name]
sig = inspect.signature(tool.fn)
assert "bank_id" in sig.parameters, f"{tool_name} should have bank_id param in multi-bank mode"
def test_single_bank_mode_tools_no_bank_id_param(mock_memory):
"""Test that single-bank mode tools do NOT include bank_id parameter."""
from hindsight_api.api.mcp import create_mcp_server
import inspect
from hindsight_api.api.mcp import create_mcp_server
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
tools = mcp_server._tool_manager._tools
# Check that tools do NOT have bank_id parameter
retain_tool = tools["retain"]
retain_sig = inspect.signature(retain_tool.fn)
assert "bank_id" not in retain_sig.parameters
recall_tool = tools["recall"]
recall_sig = inspect.signature(recall_tool.fn)
assert "bank_id" not in recall_sig.parameters
reflect_tool = tools["reflect"]
reflect_sig = inspect.signature(reflect_tool.fn)
assert "bank_id" not in reflect_sig.parameters
# No bank-scoped tool should have bank_id parameter in single-bank mode
bank_scoped_tools = [
"retain",
"recall",
"reflect",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
]
for tool_name in bank_scoped_tools:
tool = tools[tool_name]
sig = inspect.signature(tool.fn)
assert "bank_id" not in sig.parameters, f"{tool_name} should NOT have bank_id param in single-bank mode"
@pytest.mark.asyncio
@@ -242,19 +340,26 @@ async def test_middleware_handles_both_endpoints(mock_memory):
assert "recall" in multi_bank_tools
assert "list_banks" in multi_bank_tools
assert "create_bank" in multi_bank_tools
assert "list_mental_models" in multi_bank_tools
assert "create_mental_model" in multi_bank_tools
# Single-bank should only have scoped tools
assert "retain" in single_bank_tools
assert "recall" in single_bank_tools
assert "list_mental_models" in single_bank_tools
assert "create_mental_model" in single_bank_tools
assert "list_banks" not in single_bank_tools
assert "create_bank" not in single_bank_tools
@pytest.mark.asyncio
async def test_routing_logic_from_url_path():
"""Test that routing correctly selects server based on URL structure."""
"""Test that routing correctly selects server based on URL structure.
Simulates the path parsing logic from MCPMiddleware.__call__ after the
prefix has been stripped. Any first path segment is treated as a bank_id.
"""
from hindsight_api.api.mcp import MCPMiddleware
from unittest.mock import AsyncMock
# Mock memory
mock_memory = MagicMock()
@@ -263,28 +368,23 @@ async def test_routing_logic_from_url_path():
middleware = MCPMiddleware(None, mock_memory)
# Simulate different URL patterns and verify routing
# Path is what remains after stripping the /mcp prefix
test_cases = [
# (path_after_stripping_mcp, expected_bank_id_from_path, expected_bank_id, description)
# (path_after_prefix_strip, expected_bank_id_from_path, expected_bank_id, description)
("/alice/messages", True, "alice", "Bank ID in path with endpoint"),
("/my-agent-123/", True, "my-agent-123", "Bank ID in path with trailing slash"),
("ciccio/messages", True, "ciccio", "Bank ID without leading slash (after mount strip)"),
("bob", True, "bob", "Bank ID only, no leading slash"),
("/messages", False, None, "MCP endpoint, no bank ID"),
("/sse/", True, "sse", "Bank named 'sse' routes to single-bank"),
("/messages/", True, "messages", "Bank named 'messages' routes to single-bank"),
("/", False, None, "Root path, no bank ID"),
]
for path, expected_bank_from_path, expected_bank_id, description in test_cases:
# Simulate the path parsing logic with leading slash normalization
if path and not path.startswith("/"):
path = "/" + path
bank_id = None
bank_id_from_path = False
MCP_ENDPOINTS = {"sse", "messages"}
if path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
if parts[0] and parts[0] not in MCP_ENDPOINTS:
if parts[0]:
bank_id = parts[0]
bank_id_from_path = True
+584 -1
View File
@@ -1,10 +1,17 @@
"""Tests for the shared MCP tools module."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api.mcp_tools import build_content_dict, parse_timestamp
from hindsight_api.mcp_tools import (
MCPToolsConfig,
_validate_mental_model_inputs,
build_content_dict,
parse_timestamp,
register_mcp_tools,
)
class TestParseTimestamp:
@@ -61,3 +68,579 @@ class TestBuildContentDict:
result, error = build_content_dict("test content", "test_context", None)
assert error is None
assert "event_date" not in result
# =========================================================================
# Mental Model MCP Tool Tests
# =========================================================================
@pytest.fixture
def mock_memory():
"""Create a mock MemoryEngine with mental model methods."""
memory = MagicMock()
memory.list_mental_models = AsyncMock(
return_value=[
{"id": "mm-1", "name": "Coding Prefs", "source_query": "coding preferences?", "content": "Prefers Python"},
{"id": "mm-2", "name": "Goals", "source_query": "current goals?", "content": "Ship v2"},
]
)
memory.get_mental_model = AsyncMock(
return_value={
"id": "mm-1",
"name": "Coding Prefs",
"source_query": "coding preferences?",
"content": "Prefers Python",
}
)
memory.create_mental_model = AsyncMock(return_value={"id": "mm-new"})
memory.submit_async_refresh_mental_model = AsyncMock(return_value={"operation_id": "op-123"})
memory.update_mental_model = AsyncMock(
return_value={
"id": "mm-1",
"name": "Updated Name",
"source_query": "new query?",
"content": "Updated",
}
)
memory.delete_mental_model = AsyncMock(return_value=True)
return memory
@pytest.fixture
def mcp_server_with_mental_models(mock_memory):
"""Create a FastMCP server with mental model tools registered (multi-bank mode)."""
from fastmcp import FastMCP
mcp = FastMCP("test", stateless_http=True)
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
include_bank_id_param=True,
tools={
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
},
)
register_mcp_tools(mcp, mock_memory, config)
return mcp
@pytest.fixture
def mcp_server_single_bank(mock_memory):
"""Create a FastMCP server with mental model tools registered (single-bank mode)."""
from fastmcp import FastMCP
mcp = FastMCP("test")
config = MCPToolsConfig(
bank_id_resolver=lambda: "fixed-bank",
include_bank_id_param=False,
tools={
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
},
)
register_mcp_tools(mcp, mock_memory, config)
return mcp
class TestMentalModelToolRegistration:
"""Test that mental model tools are registered correctly."""
def test_tools_registered_multi_bank(self, mcp_server_with_mental_models):
tools = mcp_server_with_mental_models._tool_manager._tools
expected = {
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}
assert expected == set(tools.keys())
def test_tools_registered_single_bank(self, mcp_server_single_bank):
tools = mcp_server_single_bank._tool_manager._tools
expected = {
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}
assert expected == set(tools.keys())
@pytest.mark.asyncio
async def test_list_mental_models_propagates_request_context(self, mock_memory):
from fastmcp import FastMCP
mcp = FastMCP("test", stateless_http=True)
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
api_key_resolver=lambda: "test-api-key",
include_bank_id_param=True,
tools={"list_mental_models"},
)
register_mcp_tools(mcp, mock_memory, config)
await _tools(mcp)["list_mental_models"].fn()
request_context = mock_memory.list_mental_models.call_args.kwargs["request_context"]
assert request_context.api_key == "test-api-key"
@pytest.mark.asyncio
async def test_create_mental_model_propagates_request_context(self, mock_memory):
from fastmcp import FastMCP
mcp = FastMCP("test", stateless_http=True)
config = MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
api_key_resolver=lambda: "test-api-key",
include_bank_id_param=True,
tools={"create_mental_model"},
)
register_mcp_tools(mcp, mock_memory, config)
await _tools(mcp)["create_mental_model"].fn(name="Test", source_query="query")
request_context = mock_memory.create_mental_model.call_args.kwargs["request_context"]
assert request_context.api_key == "test-api-key"
def test_mental_model_tools_in_default_set(self):
"""Mental model tools should be in the default tools set when config.tools is None."""
from fastmcp import FastMCP
memory = MagicMock()
# Mock all engine methods that tools reference
memory.retain_batch_async = AsyncMock()
memory.submit_async_retain = AsyncMock(return_value={"operation_id": "op"})
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
memory.reflect_async = AsyncMock()
memory.list_banks = AsyncMock(return_value=[])
memory.get_bank_profile = AsyncMock(return_value={})
memory.update_bank = AsyncMock()
memory.list_mental_models = AsyncMock(return_value=[])
memory.get_mental_model = AsyncMock()
memory.create_mental_model = AsyncMock()
memory.submit_async_refresh_mental_model = AsyncMock()
memory.update_mental_model = AsyncMock()
memory.delete_mental_model = AsyncMock()
mcp = FastMCP("test", stateless_http=True)
config = MCPToolsConfig(
bank_id_resolver=lambda: "bank",
include_bank_id_param=True,
tools=None, # Default - all tools
)
register_mcp_tools(mcp, memory, config)
tools = mcp._tool_manager._tools
assert "list_mental_models" in tools
assert "create_mental_model" in tools
assert "refresh_mental_model" in tools
@pytest.fixture
def no_bank_mcp_server(mock_memory):
"""Create a multi-bank MCP server where bank_id_resolver returns None."""
from fastmcp import FastMCP
mcp = FastMCP("test", stateless_http=True)
config = MCPToolsConfig(
bank_id_resolver=lambda: None,
include_bank_id_param=True,
tools={
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
},
)
register_mcp_tools(mcp, mock_memory, config)
return mcp
def _tools(mcp_server):
"""Helper to get tools dict from MCP server."""
return mcp_server._tool_manager._tools
@pytest.mark.asyncio
class TestListMentalModels:
async def test_list_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["list_mental_models"].fn()
assert '"mm-1"' in result
assert '"mm-2"' in result
mock_memory.list_mental_models.assert_called_once()
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "test-bank"
async def test_list_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
"""Explicit bank_id should override the resolver."""
await _tools(mcp_server_with_mental_models)["list_mental_models"].fn(bank_id="other-bank")
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "other-bank"
async def test_list_with_tags(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["list_mental_models"].fn(tags=["work"])
assert mock_memory.list_mental_models.call_args.kwargs["tags"] == ["work"]
async def test_list_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["list_mental_models"].fn()
assert isinstance(result, dict)
assert len(result["items"]) == 2
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "fixed-bank"
async def test_list_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["list_mental_models"].fn()
assert "error" in result
async def test_list_engine_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.list_mental_models.side_effect = RuntimeError("DB connection lost")
result = await _tools(mcp_server_with_mental_models)["list_mental_models"].fn()
assert "error" in result
assert "DB connection lost" in result
async def test_list_engine_error_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.list_mental_models.side_effect = RuntimeError("DB connection lost")
result = await _tools(mcp_server_single_bank)["list_mental_models"].fn()
assert isinstance(result, dict)
assert "error" in result
@pytest.mark.asyncio
class TestGetMentalModel:
async def test_get_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1")
assert '"mm-1"' in result
assert mock_memory.get_mental_model.call_args.kwargs["mental_model_id"] == "mm-1"
async def test_get_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1", bank_id="other-bank")
assert mock_memory.get_mental_model.call_args.kwargs["bank_id"] == "other-bank"
async def test_get_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.get_mental_model.return_value = None
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="missing")
assert "not found" in result
async def test_get_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.get_mental_model.return_value = None
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="missing")
assert isinstance(result, dict)
assert "not found" in result["error"]
async def test_get_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="mm-1")
assert isinstance(result, dict)
assert result["id"] == "mm-1"
async def test_get_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["get_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
async def test_get_engine_error(self, mcp_server_with_mental_models, mock_memory):
mock_memory.get_mental_model.side_effect = RuntimeError("DB error")
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
@pytest.mark.asyncio
class TestCreateMentalModel:
async def test_create_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test Model",
source_query="What are the user's preferences?",
)
assert '"mm-new"' in result
assert '"op-123"' in result
mock_memory.create_mental_model.assert_called_once()
call_kwargs = mock_memory.create_mental_model.call_args.kwargs
assert call_kwargs["name"] == "Test Model"
assert call_kwargs["source_query"] == "What are the user's preferences?"
assert call_kwargs["content"] == "Generating content..."
# Verify async refresh was scheduled
mock_memory.submit_async_refresh_mental_model.assert_called_once()
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-new"
async def test_create_with_custom_id(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query", mental_model_id="custom-id"
)
assert mock_memory.create_mental_model.call_args.kwargs["mental_model_id"] == "custom-id"
async def test_create_with_tags_and_max_tokens(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query", tags=["work", "coding"], max_tokens=4096
)
call_kwargs = mock_memory.create_mental_model.call_args.kwargs
assert call_kwargs["tags"] == ["work", "coding"]
assert call_kwargs["max_tokens"] == 4096
async def test_create_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query", bank_id="other-bank"
)
assert mock_memory.create_mental_model.call_args.kwargs["bank_id"] == "other-bank"
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["bank_id"] == "other-bank"
async def test_create_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(name="Test", source_query="query")
assert isinstance(result, dict)
assert result["mental_model_id"] == "mm-new"
assert result["operation_id"] == "op-123"
async def test_create_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["create_mental_model"].fn(name="Test", source_query="query")
assert "error" in result
async def test_create_value_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
"""ValueError from engine (e.g. invalid ID format) should return error, not crash."""
mock_memory.create_mental_model.side_effect = ValueError("ID must be alphanumeric lowercase")
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query", mental_model_id="INVALID!!"
)
assert "alphanumeric" in result
async def test_create_value_error_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.create_mental_model.side_effect = ValueError("ID must be alphanumeric lowercase")
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(
name="Test", source_query="query", mental_model_id="INVALID!!"
)
assert isinstance(result, dict)
assert "alphanumeric" in result["error"]
async def test_create_engine_error(self, mcp_server_with_mental_models, mock_memory):
mock_memory.create_mental_model.side_effect = RuntimeError("DB error")
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query"
)
assert "error" in result
@pytest.mark.asyncio
class TestUpdateMentalModel:
async def test_update_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
mental_model_id="mm-1", name="Updated Name"
)
assert '"Updated Name"' in result
call_kwargs = mock_memory.update_mental_model.call_args.kwargs
assert call_kwargs["name"] == "Updated Name"
assert call_kwargs["source_query"] is None # Not updated
async def test_update_multiple_fields(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
mental_model_id="mm-1", name="New Name", source_query="new query?", tags=["updated"], max_tokens=4096
)
call_kwargs = mock_memory.update_mental_model.call_args.kwargs
assert call_kwargs["name"] == "New Name"
assert call_kwargs["source_query"] == "new query?"
assert call_kwargs["tags"] == ["updated"]
assert call_kwargs["max_tokens"] == 4096
async def test_update_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
mental_model_id="mm-1", name="X", bank_id="other-bank"
)
assert mock_memory.update_mental_model.call_args.kwargs["bank_id"] == "other-bank"
async def test_update_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.update_mental_model.return_value = None
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
mental_model_id="missing", name="X"
)
assert "not found" in result
async def test_update_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="mm-1", name="Updated")
assert isinstance(result, dict)
assert mock_memory.update_mental_model.call_args.kwargs["bank_id"] == "fixed-bank"
async def test_update_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.update_mental_model.return_value = None
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="missing", name="X")
assert isinstance(result, dict)
assert "not found" in result["error"]
async def test_update_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["update_mental_model"].fn(mental_model_id="mm-1", name="X")
assert "error" in result
async def test_update_engine_error(self, mcp_server_with_mental_models, mock_memory):
mock_memory.update_mental_model.side_effect = RuntimeError("DB error")
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(mental_model_id="mm-1", name="X")
assert "error" in result
@pytest.mark.asyncio
class TestDeleteMentalModel:
async def test_delete_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="mm-1")
assert '"deleted"' in result
assert mock_memory.delete_mental_model.call_args.kwargs["mental_model_id"] == "mm-1"
async def test_delete_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(
mental_model_id="mm-1", bank_id="other-bank"
)
assert mock_memory.delete_mental_model.call_args.kwargs["bank_id"] == "other-bank"
async def test_delete_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.delete_mental_model.return_value = False
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="missing")
assert "not found" in result
async def test_delete_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.delete_mental_model.return_value = False
result = await _tools(mcp_server_single_bank)["delete_mental_model"].fn(mental_model_id="missing")
assert isinstance(result, dict)
assert "not found" in result["error"]
async def test_delete_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["delete_mental_model"].fn(mental_model_id="mm-1")
assert isinstance(result, dict)
assert result["status"] == "deleted"
async def test_delete_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["delete_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
async def test_delete_engine_error(self, mcp_server_with_mental_models, mock_memory):
mock_memory.delete_mental_model.side_effect = RuntimeError("DB error")
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
@pytest.mark.asyncio
class TestRefreshMentalModel:
async def test_refresh_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="mm-1")
assert '"op-123"' in result
assert '"queued"' in result
async def test_refresh_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(
mental_model_id="mm-1", bank_id="other-bank"
)
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["bank_id"] == "other-bank"
async def test_refresh_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.submit_async_refresh_mental_model.side_effect = ValueError("Mental model 'missing' not found")
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="missing")
assert "not found" in result
async def test_refresh_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.submit_async_refresh_mental_model.side_effect = ValueError("not found")
result = await _tools(mcp_server_single_bank)["refresh_mental_model"].fn(mental_model_id="missing")
assert isinstance(result, dict)
assert "not found" in result["error"]
async def test_refresh_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["refresh_mental_model"].fn(mental_model_id="mm-1")
assert isinstance(result, dict)
assert result["operation_id"] == "op-123"
async def test_refresh_no_bank_returns_error(self, no_bank_mcp_server):
result = await _tools(no_bank_mcp_server)["refresh_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
async def test_refresh_engine_error(self, mcp_server_with_mental_models, mock_memory):
mock_memory.submit_async_refresh_mental_model.side_effect = RuntimeError("DB error")
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="mm-1")
assert "error" in result
class TestValidateMentalModelInputs:
"""Tests for the _validate_mental_model_inputs helper."""
def test_valid_inputs(self):
assert _validate_mental_model_inputs(name="Test", source_query="query", max_tokens=2048) is None
def test_none_inputs(self):
assert _validate_mental_model_inputs() is None
def test_empty_name(self):
result = _validate_mental_model_inputs(name="")
assert result == "name cannot be empty"
def test_whitespace_name(self):
result = _validate_mental_model_inputs(name=" ")
assert result == "name cannot be empty"
def test_empty_source_query(self):
result = _validate_mental_model_inputs(source_query="")
assert result == "source_query cannot be empty"
def test_whitespace_source_query(self):
result = _validate_mental_model_inputs(source_query=" \t ")
assert result == "source_query cannot be empty"
def test_max_tokens_too_low(self):
result = _validate_mental_model_inputs(max_tokens=0)
assert "max_tokens must be between 256 and 8192" in result
def test_max_tokens_too_high(self):
result = _validate_mental_model_inputs(max_tokens=10000)
assert "max_tokens must be between 256 and 8192" in result
def test_max_tokens_at_lower_bound(self):
assert _validate_mental_model_inputs(max_tokens=256) is None
def test_max_tokens_at_upper_bound(self):
assert _validate_mental_model_inputs(max_tokens=8192) is None
@pytest.mark.asyncio
class TestMentalModelInputValidation:
"""Tests that validation is applied in create/update tools before engine calls."""
async def test_create_empty_name_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(name="", source_query="query")
assert "name cannot be empty" in result
mock_memory.create_mental_model.assert_not_called()
async def test_create_empty_source_query_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(name="Test", source_query="")
assert "source_query cannot be empty" in result
mock_memory.create_mental_model.assert_not_called()
async def test_create_max_tokens_too_low_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
name="Test", source_query="query", max_tokens=0
)
assert "max_tokens must be between 256 and 8192" in result
mock_memory.create_mental_model.assert_not_called()
async def test_create_max_tokens_too_high_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(
name="Test", source_query="query", max_tokens=10000
)
assert isinstance(result, dict)
assert "max_tokens must be between 256 and 8192" in result["error"]
mock_memory.create_mental_model.assert_not_called()
async def test_update_empty_name_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(mental_model_id="mm-1", name="")
assert "name cannot be empty" in result
mock_memory.update_mental_model.assert_not_called()
async def test_update_empty_name_returns_error_single_bank(self, mcp_server_single_bank, mock_memory):
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="mm-1", name=" ")
assert isinstance(result, dict)
assert "name cannot be empty" in result["error"]
mock_memory.update_mental_model.assert_not_called()
async def test_not_found_error_includes_bank_id_multi_bank(self, mcp_server_with_mental_models, mock_memory):
mock_memory.get_mental_model.return_value = None
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="missing")
assert "test-bank" in result
async def test_not_found_error_includes_bank_id_single_bank(self, mcp_server_single_bank, mock_memory):
mock_memory.get_mental_model.return_value = None
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="missing")
assert isinstance(result, dict)
assert "fixed-bank" in result["error"]
@@ -0,0 +1,103 @@
"""
Test reflect endpoint with empty based_on (no memories scenario).
This test verifies that the API returns the correct based_on format:
- v0.3.0 (old): returned based_on as list []
- v0.4.0+ (current): returns based_on as object {"memories": [], "mental_models": [], "directives": []}
"""
import pytest
import pytest_asyncio
import httpx
from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
"""Create an async test client for the FastAPI app."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_reflect_with_no_memories_empty_bank(api_client):
"""Test reflect on an empty bank (no memories) with include.facts enabled."""
bank_id = "test_empty_bank"
# Reflect on empty bank with facts requested
response = await api_client.post(
f"/v1/default/banks/{bank_id}/reflect",
json={
"query": "What do you know about machine learning?",
"budget": "low",
"include": {
"facts": {} # Request facts but bank is empty
}
}
)
assert response.status_code == 200
data = response.json()
# DEBUG: Print what the API actually returned
import json
print("\n" + "="*80)
print("API Response:")
print(json.dumps(data, indent=2))
print("="*80 + "\n")
# Verify response structure
assert "text" in data
assert "based_on" in data
# The API should return based_on as either:
# 1. null/None (if include.facts not set)
# 2. {"memories": [], "mental_models": [], "directives": []} (if include.facts set but empty)
# It should NEVER return based_on: []
based_on = data.get("based_on")
if based_on is not None:
assert isinstance(based_on, dict), f"based_on should be dict or null, got {type(based_on)}: {based_on}"
assert not isinstance(based_on, list), f"based_on should NEVER be a list! Got: {based_on}"
assert "memories" in based_on
assert "mental_models" in based_on
assert "directives" in based_on
# All should be empty lists
assert based_on["memories"] == []
assert based_on["mental_models"] == []
assert based_on["directives"] == []
# Verify the structure is parseable as proper types
assert isinstance(data["text"], str)
if based_on is not None:
# Verify it's the v0.4.0+ format (object with arrays)
assert isinstance(based_on["memories"], list)
assert isinstance(based_on["mental_models"], list)
assert isinstance(based_on["directives"], list)
@pytest.mark.asyncio
async def test_reflect_without_include_facts(api_client):
"""Test reflect without requesting facts (based_on should be None)."""
bank_id = "test_no_facts"
response = await api_client.post(
f"/v1/default/banks/{bank_id}/reflect",
json={
"query": "Hello world",
"budget": "low"
# No include.facts
}
)
assert response.status_code == 200
data = response.json()
# When include.facts is not set, based_on should not be in response (or be null)
based_on = data.get("based_on")
assert based_on is None, f"based_on should be None when not requested, got {type(based_on)}: {based_on}"
# Verify structure
assert isinstance(data["text"], str)
@@ -0,0 +1,45 @@
"""
Test to verify reflect operation creates proper span hierarchy.
"""
import pytest
@pytest.mark.asyncio
async def test_reflect_creates_child_spans(memory, request_context):
"""Test that reflect operation creates child LLM spans."""
from datetime import datetime, timezone
from hindsight_api.tracing import initialize_tracing, get_span_recorder, create_span_recorder
# Initialize tracing with a mock endpoint
initialize_tracing(
service_name="test-hindsight",
endpoint="http://localhost:4318",
deployment_environment="test"
)
# Create span recorder
recorder = create_span_recorder()
bank_id = f"test-reflect-hierarchy-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
context="Geography",
request_context=request_context,
)
# Run reflect
result = await memory.reflect_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
print(f"Reflect result: {result.text[:100]}")
print(f"Usage: {result.usage}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+407
View File
@@ -0,0 +1,407 @@
"""
Unit tests for OpenTelemetry tracing instrumentation.
Tests the tracing module's ability to record LLM calls with GenAI semantic conventions.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.tracing import (
PROVIDER_NAME_MAPPING,
GenAIAttributes,
LLMSpanRecorder,
NoOpLLMSpanRecorder,
_truncate_content,
create_operation_span,
initialize_tracing,
is_tracing_enabled,
)
def test_provider_name_mapping():
"""Test that provider names are correctly mapped to GenAI conventions."""
assert PROVIDER_NAME_MAPPING["openai"] == "openai"
assert PROVIDER_NAME_MAPPING["anthropic"] == "anthropic"
assert PROVIDER_NAME_MAPPING["gemini"] == "google"
assert PROVIDER_NAME_MAPPING["vertexai"] == "google"
assert PROVIDER_NAME_MAPPING["groq"] == "groq"
assert PROVIDER_NAME_MAPPING["ollama"] == "ollama"
assert PROVIDER_NAME_MAPPING["openai-codex"] == "openai"
assert PROVIDER_NAME_MAPPING["claude-code"] == "anthropic"
def test_truncate_content_short():
"""Test that short content is not truncated."""
content = "This is a short message"
result = _truncate_content(content)
assert result == content
def test_truncate_content_long():
"""Test that long content is truncated."""
content = "x" * 150000 # Exceeds MAX_CONTENT_LENGTH
result = _truncate_content(content)
assert len(result) < len(content)
assert "[TRUNCATED:" in result
assert result.startswith("x" * 100)
def test_noop_span_recorder():
"""Test that NoOpLLMSpanRecorder doesn't raise errors."""
recorder = NoOpLLMSpanRecorder()
# Should not raise any errors
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="test",
messages=[{"role": "user", "content": "test"}],
response_content="test response",
input_tokens=10,
output_tokens=5,
duration=1.0,
)
def test_llm_span_recorder_format_messages():
"""Test message formatting to GenAI convention."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello"},
]
result = recorder._format_messages(messages)
parsed = json.loads(result)
assert len(parsed) == 2
assert parsed[0]["role"] == "system"
assert parsed[0]["content"] == "You are helpful"
assert parsed[1]["role"] == "user"
assert parsed[1]["content"] == "Hello"
def test_llm_span_recorder_format_output():
"""Test output formatting to GenAI convention."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
result = recorder._format_output("Hello world", "stop")
parsed = json.loads(result)
assert len(parsed) == 1
assert parsed[0]["role"] == "assistant"
assert parsed[0]["content"] == "Hello world"
def test_llm_span_recorder_format_output_none():
"""Test output formatting with None content."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
result = recorder._format_output(None, None)
parsed = json.loads(result)
assert parsed == []
def test_llm_span_recorder_extract_system_instructions():
"""Test system instruction extraction."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello"},
]
result = recorder._extract_system_instructions(messages)
assert result == "You are helpful"
def test_llm_span_recorder_extract_system_instructions_none():
"""Test system instruction extraction with no system message."""
mock_tracer = MagicMock()
recorder = LLMSpanRecorder(mock_tracer)
messages = [
{"role": "user", "content": "Hello"},
]
result = recorder._extract_system_instructions(messages)
assert result is None
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_record_success(mock_time):
"""Test successful LLM call recording."""
# Mock time
mock_time.time_ns.return_value = 1000000000000 # 1 second in nanoseconds
# Create mock tracer and span
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
messages = [{"role": "user", "content": "Hello"}]
response_content = "Hi there!"
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="test",
messages=messages,
response_content=response_content,
input_tokens=10,
output_tokens=5,
duration=1.5,
finish_reason="stop",
error=None,
)
# Verify span was created with correct name (hindsight.{scope})
mock_tracer.start_as_current_span.assert_called_once()
call_args = mock_tracer.start_as_current_span.call_args
assert call_args[0][0] == "hindsight.test"
# Verify attributes were set
assert mock_span.set_attribute.called
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.OPERATION_NAME] == "chat"
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "openai"
assert attribute_calls[GenAIAttributes.REQUEST_MODEL] == "gpt-4"
assert attribute_calls[GenAIAttributes.RESPONSE_MODEL] == "gpt-4"
assert attribute_calls[GenAIAttributes.USAGE_INPUT_TOKENS] == 10
assert attribute_calls[GenAIAttributes.USAGE_OUTPUT_TOKENS] == 5
assert attribute_calls["hindsight.scope"] == "test"
# Verify event was added
mock_span.add_event.assert_called_once()
event_call = mock_span.add_event.call_args
assert event_call[0][0] == "gen_ai.client.inference.operation.details"
# Verify status was set to OK
mock_span.set_status.assert_called()
# Verify span was ended
mock_span.end.assert_called_once()
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_record_error(mock_time):
"""Test error LLM call recording."""
# Mock time
mock_time.time_ns.return_value = 1000000000000
# Create mock tracer and span
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
messages = [{"role": "user", "content": "Hello"}]
error = ValueError("Test error")
recorder.record_llm_call(
provider="anthropic",
model="claude-3",
scope="test",
messages=messages,
response_content=None,
input_tokens=10,
output_tokens=0,
duration=0.5,
finish_reason=None,
error=error,
)
# Verify error status was set
mock_span.set_status.assert_called()
status_call = mock_span.set_status.call_args[0][0]
assert status_call.status_code.name == "ERROR"
# Verify error type attribute was set
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.ERROR_TYPE] == "ValueError"
# Verify exception was recorded
mock_span.record_exception.assert_called_once_with(error)
@patch("hindsight_api.tracing.time")
def test_llm_span_recorder_provider_mapping(mock_time):
"""Test that provider names are mapped correctly."""
mock_time.time_ns.return_value = 1000000000000
mock_span = MagicMock()
mock_tracer = MagicMock()
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
recorder = LLMSpanRecorder(mock_tracer)
# Test gemini -> google mapping
recorder.record_llm_call(
provider="gemini",
model="gemini-pro",
scope="test",
messages=[{"role": "user", "content": "test"}],
response_content="test",
input_tokens=5,
output_tokens=3,
duration=1.0,
)
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "google"
# ==================== Parent Span Tests ====================
def test_create_operation_span_disabled():
"""Test that create_operation_span returns no-op when tracing is disabled."""
# Tracing should be disabled by default
assert not is_tracing_enabled()
# Should return a no-op context manager
span = create_operation_span("test_operation", "test_bank_id")
# Should be usable as context manager without errors
with span:
pass
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_enabled(mock_tracer):
"""Test that create_operation_span creates a span when tracing is enabled."""
# Mock the tracer
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
# Create operation span
span = create_operation_span("retain", "bank123")
# Verify span was created with correct name
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.retain")
# Verify attributes were set
mock_span.set_attribute.assert_any_call("hindsight.operation", "retain")
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "bank123")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_no_bank_id(mock_tracer):
"""Test that create_operation_span works without bank_id."""
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
# Create operation span without bank_id
span = create_operation_span("consolidation")
# Verify span was created
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.consolidation")
# Verify only operation attribute was set (not bank_id)
assert mock_span.set_attribute.call_count == 1
mock_span.set_attribute.assert_called_once_with("hindsight.operation", "consolidation")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_create_operation_span_all_operations(mock_tracer):
"""Test that all 4 operations can create parent spans."""
mock_span = MagicMock()
mock_tracer.start_as_current_span.return_value = mock_span
operations = ["retain", "consolidation", "reflect", "mental_model_refresh"]
for operation in operations:
mock_tracer.reset_mock()
mock_span.reset_mock()
span = create_operation_span(operation, "test_bank")
# Verify span was created with correct name
mock_tracer.start_as_current_span.assert_called_once_with(f"hindsight.{operation}")
# Verify attributes
mock_span.set_attribute.assert_any_call("hindsight.operation", operation)
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "test_bank")
@patch("hindsight_api.tracing.time")
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_parent_child_span_hierarchy(mock_tracer, mock_time):
"""Test that child LLM spans are created under parent operation spans."""
mock_time.time_ns.return_value = 1000000000000
# Create mock parent span
mock_parent_span = MagicMock()
mock_parent_span.__enter__ = MagicMock(return_value=mock_parent_span)
mock_parent_span.__exit__ = MagicMock(return_value=False)
# Create mock child span
mock_child_span = MagicMock()
# Mock tracer to return parent span first, then child span
mock_tracer.start_as_current_span.side_effect = [
mock_parent_span, # Parent span
MagicMock(__enter__=MagicMock(return_value=mock_child_span), __exit__=MagicMock(return_value=False)), # Child
]
# Create parent operation span
with create_operation_span("retain", "bank123"):
# Simulate creating a child LLM span
recorder = LLMSpanRecorder(mock_tracer)
recorder.record_llm_call(
provider="openai",
model="gpt-4",
scope="retain_extract_facts",
messages=[{"role": "user", "content": "test"}],
response_content="response",
input_tokens=10,
output_tokens=5,
duration=1.0,
)
# Verify both parent and child spans were created
assert mock_tracer.start_as_current_span.call_count == 2
# Verify parent span was created first
first_call = mock_tracer.start_as_current_span.call_args_list[0]
assert first_call[0][0] == "hindsight.retain"
# Verify child span was created second (hindsight.{scope})
second_call = mock_tracer.start_as_current_span.call_args_list[1]
assert second_call[0][0] == "hindsight.retain_extract_facts"
@patch("hindsight_api.tracing._tracer")
@patch("hindsight_api.tracing._tracing_enabled", True)
def test_operation_span_context_manager(mock_tracer):
"""Test that operation spans work as context managers."""
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_tracer.start_as_current_span.return_value = mock_span
# Use span as context manager
with create_operation_span("reflect", "bank456"):
# Do some work
pass
# Verify span lifecycle
mock_tracer.start_as_current_span.assert_called_once()
mock_span.__enter__.assert_called_once()
mock_span.__exit__.assert_called_once()
@@ -0,0 +1,196 @@
"""
Integration tests for OpenTelemetry tracing with memory engine operations.
Tests that parent spans are correctly created for retain, consolidation, reflect,
and mental_model_refresh operations.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_retain_creates_parent_span(mock_create_span, memory, request_context):
"""Test that retain operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-retain-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute retain (automatically creates bank if needed)
await memory.retain_async(
bank_id=bank_id,
content="Test memory for tracing",
context="Test context",
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "retain" # operation name
assert call_args[0][1] == bank_id # bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_consolidation_creates_parent_span(mock_create_span, memory, request_context):
"""Test that consolidation operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-consolidation-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute consolidation (bank will be created automatically)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "consolidation"
assert call_args[0][1] == bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_reflect_creates_parent_span(mock_create_span, memory, request_context):
"""Test that reflect operation creates a parent span."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-reflect-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories first
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
context="Geography fact",
request_context=request_context,
)
# Reset mock to clear retain call
mock_create_span.reset_mock()
# Execute reflect
await memory.reflect_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
# Verify parent span was created
mock_create_span.assert_called()
call_args = mock_create_span.call_args
assert call_args[0][0] == "reflect"
assert call_args[0][1] == bank_id
# Verify span was used as context manager
mock_span.__enter__.assert_called()
mock_span.__exit__.assert_called()
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_retain_batch_creates_single_parent_span(mock_create_span, memory, request_context):
"""Test that batch retain creates one parent span for the entire batch."""
# Setup
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_create_span.return_value = mock_span
bank_id = f"test-batch-{datetime.now(timezone.utc).timestamp()}"
try:
# Execute batch retain with multiple items
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Memory 1", "context": "Context 1"},
{"content": "Memory 2", "context": "Context 2"},
{"content": "Memory 3", "context": "Context 3"},
],
request_context=request_context,
)
# Verify parent span was created only once for the entire batch
assert mock_create_span.call_count == 1
call_args = mock_create_span.call_args
assert call_args[0][0] == "retain"
assert call_args[0][1] == bank_id
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.tracing._tracing_enabled", False)
@patch("hindsight_api.engine.memory_engine.create_operation_span")
async def test_operations_work_when_tracing_disabled(mock_create_span, memory, request_context):
"""Test that operations work correctly when tracing is disabled."""
# Setup - create_operation_span should return a no-op context manager
from contextlib import nullcontext
mock_create_span.return_value = nullcontext()
bank_id = f"test-no-trace-{datetime.now(timezone.utc).timestamp()}"
try:
# All operations should work without errors
await memory.retain_async(
bank_id=bank_id,
content="Test memory",
request_context=request_context,
)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
await memory.reflect_async(
bank_id=bank_id,
query="Test query",
request_context=request_context,
)
# Verify no errors occurred and spans were attempted to be created
assert mock_create_span.call_count >= 3
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,273 @@
"""
Comprehensive tracing span verification tests.
Verifies that all memory engine operations create correct parent and child spans
with proper attributes and hierarchy.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.asyncio
@pytest.mark.skip(reason="Background consolidation causes StopIteration - need to investigate separately")
@patch("hindsight_api.tracing._tracing_enabled", True)
@patch("hindsight_api.tracing._tracer")
async def test_recall_span_hierarchy(mock_tracer, memory, request_context):
"""Test that recall creates proper parent and child spans."""
# Setup mock spans
mock_recall_span = MagicMock()
mock_recall_span.__enter__ = MagicMock(return_value=mock_recall_span)
mock_recall_span.__exit__ = MagicMock(return_value=False)
mock_embedding_span = MagicMock()
mock_retrieval_span = MagicMock()
mock_fusion_span = MagicMock()
mock_rerank_span = MagicMock()
# Mock tracer to return spans in sequence
mock_tracer.start_as_current_span.side_effect = [mock_recall_span]
mock_tracer.start_span.side_effect = [
mock_embedding_span,
mock_retrieval_span,
mock_fusion_span,
mock_rerank_span,
]
bank_id = f"test-recall-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories first
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
# Wait a bit for any background tasks to settle
import asyncio
await asyncio.sleep(0.5)
# Reset mocks after retain
mock_tracer.reset_mock()
mock_recall_span.reset_mock()
# Execute recall
await memory.recall_async(
bank_id=bank_id,
query="What is the capital of France?",
request_context=request_context,
)
# Verify parent span was created with start_as_current_span
assert mock_tracer.start_as_current_span.called
parent_call = mock_tracer.start_as_current_span.call_args
assert parent_call[0][0] == "hindsight.recall"
# Verify parent span attributes were set
recall_attrs = {call[0][0]: call[0][1] for call in mock_recall_span.set_attribute.call_args_list}
assert "hindsight.bank_id" in recall_attrs
assert recall_attrs["hindsight.bank_id"] == bank_id
assert "hindsight.query" in recall_attrs
assert "hindsight.fact_types" in recall_attrs
assert "hindsight.thinking_budget" in recall_attrs
assert "hindsight.max_tokens" in recall_attrs
# Verify child spans were created (if tracing is enabled)
if mock_tracer.start_span.called:
child_spans = [call[0][0] for call in mock_tracer.start_span.call_args_list]
assert "hindsight.recall_embedding" in child_spans
assert "hindsight.recall_retrieval" in child_spans
assert "hindsight.recall_fusion" in child_spans
assert "hindsight.recall_rerank" in child_spans
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_mental_model_refresh_span_exists(memory, request_context):
"""Test that mental model refresh functionality exists (span creation tested via unit tests)."""
# This test verifies that refresh_mental_model method exists and can be called
# The actual span creation is tested in unit tests with proper mocking
bank_id = f"test-mmr-{datetime.now(timezone.utc).timestamp()}"
try:
# Just verify the method exists - it will return None if no mental model found
result = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id="non-existent-id",
request_context=request_context,
)
# Result will be None since mental model doesn't exist
assert result is None
finally:
# Cleanup
try:
await memory.delete_bank(bank_id, request_context=request_context)
except Exception:
pass
@pytest.mark.asyncio
async def test_consolidation_child_spans(memory, request_context):
"""Test that consolidation creates child spans for its operations."""
bank_id = f"test-cons-child-{datetime.now(timezone.utc).timestamp()}"
try:
# Add memories to consolidate
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is in Paris",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
# Run consolidation (this will create parent + child spans)
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# Note: We can't easily verify the child spans without mocking the tracer,
# but we can verify that consolidation completes successfully
# The actual span creation is tested in unit tests
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_reflect_tool_call_spans(memory, request_context):
"""Test that reflect creates tool call spans (not reflect_generation)."""
bank_id = f"test-reflect-tools-{datetime.now(timezone.utc).timestamp()}"
try:
# Add some memories
await memory.retain_async(
bank_id=bank_id,
content="Machine learning is a subset of AI",
request_context=request_context,
)
# Execute reflect (will create reflect_tool_call spans)
result = await memory.reflect_async(
bank_id=bank_id,
query="What is machine learning?",
request_context=request_context,
)
# Verify reflect completed successfully
assert result.text
assert len(result.text) > 0
# The span names are verified via unit tests with mocked tracers
# This integration test ensures the operation completes successfully
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_all_operations_create_spans(memory, request_context):
"""Comprehensive test that all operations create their respective spans."""
bank_id = f"test-all-ops-{datetime.now(timezone.utc).timestamp()}"
try:
# 1. Retain operation
await memory.retain_async(
bank_id=bank_id,
content="Test memory for comprehensive span test",
request_context=request_context,
)
# 2. Recall operation
await memory.recall_async(
bank_id=bank_id,
query="test memory",
request_context=request_context,
)
# 3. Reflect operation
await memory.reflect_async(
bank_id=bank_id,
query="What can you tell me about the test?",
request_context=request_context,
)
# 4. Consolidation operation
await memory.run_consolidation(
bank_id=bank_id,
request_context=request_context,
)
# All operations completed successfully
# Span hierarchy verification is done in unit tests with mocked tracers
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@patch("hindsight_api.tracing._tracing_enabled", True)
@patch("hindsight_api.tracing._tracer")
async def test_recall_span_attributes(mock_tracer, memory, request_context):
"""Verify that recall spans have all required attributes."""
# Setup mock span
mock_span = MagicMock()
mock_span.__enter__ = MagicMock(return_value=mock_span)
mock_span.__exit__ = MagicMock(return_value=False)
mock_tracer.start_as_current_span.return_value = mock_span
bank_id = f"test-attrs-{datetime.now(timezone.utc).timestamp()}"
try:
# Add memory
await memory.retain_async(
bank_id=bank_id,
content="Test content for attributes",
request_context=request_context,
)
# Reset mock
mock_span.reset_mock()
# Execute recall with specific parameters
await memory.recall_async(
bank_id=bank_id,
query="test query for attributes",
fact_type=["world", "experience"],
max_tokens=2048,
request_context=request_context,
)
# Collect all attributes set on the span
attrs = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
# Verify required attributes
assert "hindsight.bank_id" in attrs
assert "hindsight.query" in attrs
assert "hindsight.fact_types" in attrs
assert "hindsight.max_tokens" in attrs
assert "hindsight.thinking_budget" in attrs
# Verify attribute values
assert attrs["hindsight.bank_id"] == bank_id
assert "test query" in attrs["hindsight.query"]
assert attrs["hindsight.max_tokens"] == 2048
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,125 @@
"""
Test ReflectResponse parsing for different API versions.
This tests the client's ability to parse reflect responses from:
- v0.3.0 API (based_on as list)
- v0.4.0+ API (based_on as object)
"""
import pytest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
def test_parse_v4_format_with_empty_based_on():
"""Test parsing v0.4.0+ format with empty based_on object."""
response_data = {
"text": "I don't have any information about that.",
"based_on": {
"memories": [],
"mental_models": [],
"directives": []
}
}
response = ReflectResponse.from_dict(response_data)
assert response is not None
assert response.text == "I don't have any information about that."
assert response.based_on is not None
assert isinstance(response.based_on, ReflectBasedOn)
assert response.based_on.memories == []
assert response.based_on.mental_models == []
assert response.based_on.directives == []
def test_parse_v4_format_with_null_based_on():
"""Test parsing v0.4.0+ format with null based_on (include.facts not set)."""
response_data = {
"text": "Hello!",
"based_on": None
}
response = ReflectResponse.from_dict(response_data)
assert response is not None
assert response.text == "Hello!"
assert response.based_on is None
def test_parse_v4_format_with_populated_based_on():
"""Test parsing v0.4.0+ format with actual facts."""
response_data = {
"text": "Based on my knowledge, AI is transformative.",
"based_on": {
"memories": [
{
"id": "mem-123",
"text": "AI is used in healthcare",
"type": "world",
"context": None,
"occurred_start": None,
"occurred_end": None
}
],
"mental_models": [
{
"id": "mm-456",
"text": "AI transforms industries",
"context": "technology trends"
}
],
"directives": [
{
"id": "dir-789",
"name": "Be concise",
"content": "Keep responses brief"
}
]
}
}
response = ReflectResponse.from_dict(response_data)
assert response is not None
assert response.text == "Based on my knowledge, AI is transformative."
assert response.based_on is not None
assert len(response.based_on.memories) == 1
assert response.based_on.memories[0].id == "mem-123"
assert len(response.based_on.mental_models) == 1
assert response.based_on.mental_models[0].id == "mm-456"
assert len(response.based_on.directives) == 1
assert response.based_on.directives[0].id == "dir-789"
def test_parse_v3_format_with_empty_list_fails():
"""
Test that v0.3.0 format (based_on as list) fails validation.
This is a BREAKING CHANGE from v0.3.0 to v0.4.0.
Clients using v0.4.x SDK cannot parse v0.3.0 API responses.
Users must either:
- Upgrade API to v0.4.0+
- Use v0.3.0 client with v0.3.0 API
"""
response_data = {
"text": "No information available.",
"based_on": [] # v0.3.0 format - incompatible with v0.4.0+ client
}
with pytest.raises(Exception) as exc_info:
ReflectResponse.from_dict(response_data)
# Should fail with validation error
assert "ValidationError" in str(type(exc_info.value).__name__) or "validation" in str(exc_info.value).lower()
def test_parse_missing_based_on_field():
"""Test parsing response when based_on field is omitted entirely."""
response_data = {
"text": "Hello!"
# based_on field not present
}
response = ReflectResponse.from_dict(response_data)
assert response is not None
assert response.text == "Hello!"
assert response.based_on is None
+95 -11
View File
@@ -269,15 +269,16 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
|----------|-------------|---------|
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `cohere`, or `litellm` | `local` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
| `HINDSIGHT_API_EMBEDDINGS_TEI_URL` | TEI server URL | - |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY` | OpenAI API key (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL` | OpenAI embedding model | `text-embedding-3-small` |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL` | Custom base URL for OpenAI-compatible API (e.g., Azure OpenAI) | - |
| `HINDSIGHT_API_COHERE_API_KEY` | Cohere API key (shared for embeddings and reranker) | - |
| `HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY` | Cohere API key for embeddings | - |
| `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` |
| `HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
| `HINDSIGHT_API_LITELLM_API_BASE` | LiteLLM proxy base URL (shared for embeddings and reranker) | `http://localhost:4000` |
| `HINDSIGHT_API_LITELLM_API_KEY` | LiteLLM proxy API key (optional, depends on proxy config) | - |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE` | LiteLLM proxy base URL for embeddings | `http://localhost:4000` |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY` | LiteLLM proxy API key for embeddings (optional, depends on proxy config) | - |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL` | LiteLLM embedding model (use provider prefix, e.g., `cohere/embed-english-v3.0`) | `text-embedding-3-small` |
```bash
@@ -285,6 +286,11 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Local with custom model requiring trust_remote_code
# WARNING: Only enable trust_remote_code for models you trust (security risk)
# export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=your-custom-model
# export HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE=true
# OpenAI - cloud-based embeddings
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxxxxxxxxxx # or reuses HINDSIGHT_API_LLM_API_KEY
@@ -302,19 +308,19 @@ export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# Cohere - cloud-based embeddings
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 # 1024 dimensions
# Azure-hosted Cohere - embeddings via custom endpoint
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0
export HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
# LiteLLM proxy - unified gateway for multiple providers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL=text-embedding-3-small # or cohere/embed-english-v3.0
```
@@ -341,11 +347,15 @@ Supported OpenAI embedding dimensions:
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, `cohere`, `flashrank`, `litellm`, or `rrf` | `local` |
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
| `HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT` | Max concurrent local reranking (prevents CPU thrashing under load) | `4` |
| `HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
| `HINDSIGHT_API_RERANKER_TEI_URL` | TEI server URL | - |
| `HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE` | Batch size for TEI reranking | `128` |
| `HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT` | Max concurrent TEI reranking requests | `8` |
| `HINDSIGHT_API_RERANKER_COHERE_API_KEY` | Cohere API key for reranking | - |
| `HINDSIGHT_API_RERANKER_COHERE_MODEL` | Cohere rerank model | `rerank-english-v3.0` |
| `HINDSIGHT_API_RERANKER_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
| `HINDSIGHT_API_RERANKER_LITELLM_API_BASE` | LiteLLM proxy base URL for reranking | `http://localhost:4000` |
| `HINDSIGHT_API_RERANKER_LITELLM_API_KEY` | LiteLLM proxy API key for reranking (optional, depends on proxy config) | - |
| `HINDSIGHT_API_RERANKER_LITELLM_MODEL` | LiteLLM rerank model (use provider prefix, e.g., `cohere/rerank-english-v3.0`) | `cohere/rerank-english-v3.0` |
| `HINDSIGHT_API_RERANKER_FLASHRANK_MODEL` | FlashRank model for fast CPU-based reranking | `ms-marco-MiniLM-L-12-v2` |
| `HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR` | Cache directory for FlashRank models | System default |
@@ -355,25 +365,31 @@ Supported OpenAI embedding dimensions:
export HINDSIGHT_API_RERANKER_PROVIDER=local
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# Local with custom model requiring trust_remote_code (e.g., jina-reranker-v2)
# WARNING: Only enable trust_remote_code for models you trust (security risk)
export HINDSIGHT_API_RERANKER_PROVIDER=local
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=jinaai/jina-reranker-v2-base-multilingual
export HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE=true
# TEI - for high-performance inference
export HINDSIGHT_API_RERANKER_PROVIDER=tei
export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Cohere - cloud-based reranking
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-api-key # shared with embeddings
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
# Azure-hosted Cohere - reranking via custom endpoint
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
export HINDSIGHT_API_RERANKER_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
# LiteLLM proxy - unified gateway for multiple reranking providers
export HINDSIGHT_API_RERANKER_PROVIDER=litellm
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_RERANKER_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_RERANKER_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_RERANKER_LITELLM_MODEL=cohere/rerank-english-v3.0 # or voyage/rerank-2, together_ai/...
```
@@ -558,6 +574,74 @@ await memory.initialize()
---
## Observability & Tracing
Hindsight provides OpenTelemetry-based observability for LLM calls, conforming to GenAI semantic conventions.
### OpenTelemetry Tracing
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_OTEL_TRACES_ENABLED` | Enable distributed tracing for LLM calls | `false` |
| `HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint URL (e.g., Grafana LGTM, Langfuse, etc.) | - |
| `HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS` | Headers for OTLP exporter (format: "key1=value1,key2=value2") | - |
| `HINDSIGHT_API_OTEL_SERVICE_NAME` | Service name for traces | `hindsight-api` |
| `HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT` | Deployment environment name (e.g., development, staging, production) | `development` |
**Features:**
- Full prompts and completions recorded as events
- Token usage tracking (input/output)
- Model and provider information
- Error tracking with finish reasons
- Conforms to OpenTelemetry GenAI semantic conventions v1.37+
**OTLP-Compatible Backends:**
The tracing implementation uses standard OTLP HTTP protocol, so it works with any OTLP-compatible backend:
- **Grafana LGTM** (Recommended for local dev): All-in-one stack with Tempo traces, Loki logs, Mimir metrics, and Grafana UI
- **Langfuse**: LLM-focused observability and analytics
- **OpenLIT**: Built-in LLM dashboards, cost tracking
- **DataDog, New Relic, Honeycomb**: Commercial platforms
**Example Configuration:**
```bash
# Enable tracing
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
# Configure endpoint (example: OpenLIT Cloud)
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.openlit.io
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer olit-xxx"
# Optional: Custom service name and environment
export HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
export HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
```
**Local Development:**
For local development, we recommend the Grafana LGTM stack which provides traces, metrics, and logs in a single container:
```bash
./scripts/dev/start-grafana.sh
```
See `scripts/dev/grafana/README.md` for detailed setup instructions.
Other options: See `scripts/dev/openlit/README.md` for OpenLIT or `scripts/dev/jaeger/README.md` for standalone Jaeger.
### Metrics
Hindsight exposes Prometheus metrics at the `/metrics` endpoint, including:
- LLM call duration and token usage
- Operation duration (retain/recall/reflect)
- HTTP request metrics
- Database connection pool metrics
Metrics are always enabled and available at `http://localhost:8888/metrics`.
---
## Control Plane
The Control Plane is the web UI for managing memory banks.
+78 -7
View File
@@ -1,22 +1,30 @@
# Monitoring
Hindsight provides comprehensive monitoring through Prometheus metrics and pre-built Grafana dashboards.
Hindsight provides comprehensive observability through Prometheus metrics, OpenTelemetry distributed tracing, and pre-built Grafana dashboards.
## Local Development
For local metrics visualization, a convenience script downloads and runs Prometheus and Grafana:
For local observability, use the Grafana LGTM (Loki, Grafana, Tempo, Mimir) all-in-one stack:
```bash
./scripts/dev/start-monitoring.sh
```
This will start:
- **Grafana**: http://localhost:8890 (anonymous access enabled)
- **Prometheus**: http://localhost:8889
- **API Metrics**: http://localhost:8888/metrics
This starts a single Docker container providing:
- **Grafana UI**: http://localhost:3000 (anonymous admin access)
- **Traces (Tempo)**: OTLP endpoint at http://localhost:4318 (HTTP) and http://localhost:4317 (gRPC)
- **Metrics (Prometheus/Mimir)**: Scrapes http://localhost:8888/metrics automatically
- **Logs (Loki)**: Available for log aggregation
- **Pre-built Dashboards**: Hindsight Operations, LLM Metrics, API Service
**Enable tracing in your API:**
```bash
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
```
:::note Production Deployment
The local monitoring script is for development only. In production, you need to install and configure Prometheus and Grafana separately, then point Prometheus to scrape your Hindsight API's `/metrics` endpoint.
The local monitoring stack is for development only. In production, deploy Grafana LGTM separately or use commercial platforms (Grafana Cloud, DataDog, New Relic, etc.).
:::
## Grafana Dashboards
@@ -197,3 +205,66 @@ hindsight_db_pool_size - hindsight_db_pool_idle
```promql
rate(hindsight_process_cpu_seconds{type="user"}[1m])
```
---
## Distributed Tracing
Hindsight supports OpenTelemetry distributed tracing for memory operations and LLM calls, following GenAI semantic conventions v1.37+.
### Configuration
See [Configuration - OpenTelemetry Tracing](./configuration#opentelemetry-tracing) for environment variables.
**Quick Start:**
```bash
# Enable tracing
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# View traces with Grafana LGTM (local dev)
./scripts/dev/start-monitoring.sh
# Open http://localhost:3000 → Explore → Tempo
```
Supports any OTLP-compatible backend (Grafana LGTM, Langfuse, OpenLIT, DataDog, New Relic, Honeycomb, etc.).
### Span Hierarchy
**Parent Spans (Operations):**
- `hindsight.retain` - Memory ingestion
- `hindsight.recall` - Memory retrieval
- `hindsight.recall_embedding` - Query embedding
- `hindsight.recall_retrieval` - Parallel search (semantic, BM25, graph, temporal)
- `hindsight.recall_fusion` - Reciprocal Rank Fusion
- `hindsight.recall_rerank` - Cross-encoder reranking
- `hindsight.reflect` - Agentic reasoning
- `hindsight.reflect_tool_call` - Tool execution (recall, lookup, etc.)
- `hindsight.consolidation` - Observation synthesis
- `hindsight.mental_model_refresh` - Mental model updates
**Child Spans (LLM Calls):**
- Named by scope (e.g., `hindsight.memory`, `hindsight.reflect`)
- Contain full prompts/completions as events
- Follow GenAI semantic conventions for attributes
### Span Attributes
**Operation Spans:**
- `hindsight.operation` - Operation type
- `hindsight.bank_id` - Memory bank ID
- `hindsight.query` - Query text (truncated to 100 chars)
- `hindsight.fact_types` - Fact types for recall
- `hindsight.thinking_budget` - Budget allocation
- `hindsight.max_tokens` - Token limit
**LLM Spans (GenAI Semantic Conventions):**
- `gen_ai.operation.name` - Always `"chat"`
- `gen_ai.provider.name` - Provider (`openai`, `anthropic`, `google`, etc.)
- `gen_ai.request.model` - Model name
- `gen_ai.usage.input_tokens` - Input tokens
- `gen_ai.usage.output_tokens` - Output tokens
- `hindsight.scope` - LLM call purpose (`memory`, `reflect`, `consolidation`, etc.)
**Events:**
- `gen_ai.client.inference.operation.details` - Full prompts and completions
@@ -136,7 +136,7 @@
}
[data-theme='dark'] .cardDescription {
color: var(--ifm-color-emphasis-700);
color: var(--ifm-color-emphasis-600);
}
.cardTags {
@@ -0,0 +1,115 @@
.container {
margin: 0 0 20px 0;
width: 100%;
}
.banner {
background: linear-gradient(135deg, #0074d9 0%, #005db0 100%);
border-radius: 8px;
padding: 10px 14px;
color: white;
box-shadow: 0 2px 8px rgba(0, 116, 217, 0.12);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 12px;
transition: box-shadow 0.2s ease;
}
.banner:hover {
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.2);
}
.icon {
font-size: 20px;
line-height: 1;
flex-shrink: 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 12px;
line-height: 1.4;
color: white;
}
.commandWrapper {
margin-top: 2px;
display: flex;
align-items: center;
gap: 6px;
}
.command {
background: rgba(0, 0, 0, 0.25);
padding: 6px 8px;
border-radius: 4px;
font-size: 10px;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e6f7f8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.copyButton {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 6px 8px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
height: 28px;
width: 28px;
}
.copyButton:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.copyButton:active {
transform: scale(0.95);
}
.copyButton svg {
display: block;
}
/* Dark mode - make it stand out even more */
html[data-theme='dark'] .banner {
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.15);
}
html[data-theme='dark'] .banner:hover {
box-shadow: 0 6px 16px rgba(0, 116, 217, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.2);
}
/* Light mode adjustments */
html[data-theme='light'] .banner {
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.15),
0 2px 4px rgba(0, 0, 0, 0.05);
}
html[data-theme='light'] .banner:hover {
box-shadow: 0 6px 16px rgba(0, 116, 217, 0.25),
0 4px 8px rgba(0, 0, 0, 0.08);
}
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import styles from './SkillBanner.module.css';
export default function SkillBanner(): JSX.Element {
const [copied, setCopied] = useState(false);
const command = 'npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs';
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className={styles.container}>
<div className={styles.banner}>
<div className={styles.icon}>🤖</div>
<div className={styles.content}>
<div className={styles.title}>
Using a coding agent? Install the docs skill for instant access
</div>
<div className={styles.commandWrapper}>
<code className={styles.command}>
{command}
</code>
<button
className={styles.copyButton}
onClick={handleCopy}
aria-label="Copy command"
title={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
.toastContainer {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
}
.toastContainer.show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.toast {
background: linear-gradient(135deg, #0074d9 0%, #005db0 100%);
color: white;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
align-items: flex-start;
gap: 12px;
max-width: 420px;
min-width: 320px;
backdrop-filter: blur(10px);
}
.icon {
font-size: 24px;
flex-shrink: 0;
line-height: 1;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.title {
font-weight: 600;
font-size: 15px;
line-height: 1.4;
}
.message {
font-size: 13px;
line-height: 1.5;
opacity: 0.95;
}
.command {
background: rgba(0, 0, 0, 0.25);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace;
display: block;
margin-top: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e6f7f8;
overflow-x: auto;
}
.closeButton {
background: none;
border: none;
color: white;
font-size: 24px;
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 4px;
}
.closeButton:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
.closeButton:active {
background: rgba(255, 255, 255, 0.15);
}
/* Mobile responsive */
@media (max-width: 768px) {
.toastContainer {
bottom: 16px;
right: 16px;
left: 16px;
}
.toast {
min-width: unset;
max-width: unset;
width: 100%;
padding: 14px 16px;
}
.command {
font-size: 11px;
padding: 6px 10px;
}
}
/* Dark mode adjustments (if needed) */
html[data-theme='dark'] .toast {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.15);
}
@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react';
import styles from './SkillToast.module.css';
const STORAGE_KEY = 'hindsight-skill-toast-dismissed';
export default function SkillToast(): JSX.Element | null {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
// Check if user has already dismissed the toast
const dismissed = localStorage.getItem(STORAGE_KEY);
if (!dismissed) {
// Show toast after a short delay
const timer = setTimeout(() => {
setIsVisible(true);
setIsAnimating(true);
}, 1500);
return () => clearTimeout(timer);
}
}, []);
const handleDismiss = () => {
setIsAnimating(false);
setTimeout(() => {
setIsVisible(false);
localStorage.setItem(STORAGE_KEY, 'true');
}, 300); // Match animation duration
};
if (!isVisible) return null;
return (
<div className={`${styles.toastContainer} ${isAnimating ? styles.show : ''}`}>
<div className={styles.toast}>
<div className={styles.icon}>🤖</div>
<div className={styles.content}>
<div className={styles.title}>Building with a coding agent?</div>
<div className={styles.message}>
Install the Hindsight documentation skill for faster development:
</div>
<code className={styles.command}>
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
</code>
</div>
<button
className={styles.closeButton}
onClick={handleDismiss}
aria-label="Dismiss notification"
>
×
</button>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import React from 'react';
import Breadcrumbs from '@theme-original/DocBreadcrumbs';
import type BreadcrumbsType from '@theme/DocBreadcrumbs';
import type {WrapperProps} from '@docusaurus/types';
import SkillBanner from '@site/src/components/SkillBanner';
type Props = WrapperProps<typeof BreadcrumbsType>;
export default function BreadcrumbsWrapper(props: Props): JSX.Element {
return (
<>
<Breadcrumbs {...props} />
<SkillBanner />
</>
);
}
@@ -3,6 +3,7 @@ import Sidebar from '@theme-original/DocPage/Layout/Sidebar';
import type SidebarType from '@theme/DocPage/Layout/Sidebar';
import type {WrapperProps} from '@docusaurus/types';
import {useLocation} from '@docusaurus/router';
import SkillBanner from '@site/src/components/SkillBanner';
type Props = WrapperProps<typeof SidebarType>;
@@ -15,5 +16,10 @@ export default function SidebarWrapper(props: Props): JSX.Element | null {
return null;
}
return <Sidebar {...props} />;
return (
<>
<SkillBanner />
<Sidebar {...props} />
</>
);
}
@@ -0,0 +1,10 @@
import React from 'react';
import Content from '@theme-original/DocSidebar/Desktop/Content';
import type ContentType from '@theme/DocSidebar/Desktop/Content';
import type {WrapperProps} from '@docusaurus/types';
type Props = WrapperProps<typeof ContentType>;
export default function ContentWrapper(props: Props): JSX.Element {
return <Content {...props} />;
}
+26 -13
View File
@@ -3560,7 +3560,8 @@
},
"type": "array",
"title": "Tags",
"description": "Tags for filtering"
"description": "Tags for filtering",
"default": []
}
},
"type": "object",
@@ -3601,7 +3602,8 @@
},
"type": "array",
"title": "Tags",
"description": "Tags for scoped visibility"
"description": "Tags for scoped visibility",
"default": []
},
"max_tokens": {
"type": "integer",
@@ -3613,7 +3615,8 @@
},
"trigger": {
"$ref": "#/components/schemas/MentalModelTrigger",
"description": "Trigger settings"
"description": "Trigger settings",
"default": {}
}
},
"type": "object",
@@ -3789,7 +3792,8 @@
"type": "string"
},
"type": "array",
"title": "Tags"
"title": "Tags",
"default": []
},
"created_at": {
"anyOf": [
@@ -3905,7 +3909,8 @@
},
"type": "array",
"title": "Tags",
"description": "Tags associated with this document"
"description": "Tags associated with this document",
"default": []
}
},
"type": "object",
@@ -4686,7 +4691,8 @@
"type": "string"
},
"type": "array",
"title": "Tags"
"title": "Tags",
"default": []
},
"max_tokens": {
"type": "integer",
@@ -4694,7 +4700,8 @@
"default": 2048
},
"trigger": {
"$ref": "#/components/schemas/MentalModelTrigger"
"$ref": "#/components/schemas/MentalModelTrigger",
"default": {}
},
"last_refreshed_at": {
"anyOf": [
@@ -5008,7 +5015,8 @@
},
"include": {
"$ref": "#/components/schemas/IncludeOptions",
"description": "Options for including additional data (entities are included by default)"
"description": "Options for including additional data (entities are included by default)",
"default": {}
},
"tags": {
"anyOf": [
@@ -5333,7 +5341,8 @@
},
"type": "array",
"title": "Memories",
"description": "Memory facts used to generate the response"
"description": "Memory facts used to generate the response",
"default": []
},
"mental_models": {
"items": {
@@ -5341,7 +5350,8 @@
},
"type": "array",
"title": "Mental Models",
"description": "Mental models used during reflection"
"description": "Mental models used during reflection",
"default": []
},
"directives": {
"items": {
@@ -5349,7 +5359,8 @@
},
"type": "array",
"title": "Directives",
"description": "Directives applied during reflection"
"description": "Directives applied during reflection",
"default": []
}
},
"type": "object",
@@ -5825,7 +5836,8 @@
},
"type": "array",
"title": "Tool Calls",
"description": "Tool calls made during reflection"
"description": "Tool calls made during reflection",
"default": []
},
"llm_calls": {
"items": {
@@ -5833,7 +5845,8 @@
},
"type": "array",
"title": "Llm Calls",
"description": "LLM calls made during reflection"
"description": "LLM calls made during reflection",
"default": []
}
},
"type": "object",
+8
View File
@@ -13,7 +13,11 @@
},
"hindsight-clients/typescript": {
"name": "@vectorize-io/hindsight-client",
<<<<<<< HEAD
"version": "0.4.9",
=======
"version": "0.4.8",
>>>>>>> 23f916f (feat: add otel traceability)
"license": "MIT",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.0",
@@ -131,7 +135,11 @@
},
"hindsight-control-plane": {
"name": "@vectorize-io/hindsight-control-plane",
<<<<<<< HEAD
"version": "0.4.9",
=======
"version": "0.4.8",
>>>>>>> 23f916f (feat: add otel traceability)
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
+84
View File
@@ -0,0 +1,84 @@
# Hindsight Monitoring Stack
Docker-based monitoring stack using **Grafana LGTM** (Loki, Grafana, Tempo, Mimir) for complete observability.
## Quick Start
```bash
# Start the monitoring stack
./scripts/dev/start-monitoring.sh
# Or manually with docker-compose
cd scripts/dev/monitoring && docker-compose up -d
```
## Access
- **Grafana UI**: http://localhost:3000
- No login required (anonymous admin enabled for dev)
## Features
- **Traces**: OpenTelemetry traces with GenAI semantic conventions (Tempo)
- **Metrics**: Prometheus scraping of Hindsight API `/metrics` endpoint
- **Logs**: Loki log aggregation (future)
- **Dashboards**: Pre-configured dashboards from `monitoring/grafana/dashboards/`:
- Hindsight Operations
- Hindsight LLM Metrics
- Hindsight API Service
## Configure Hindsight API
Set these environment variables in your `.env`:
```bash
# Enable tracing
HINDSIGHT_API_OTEL_TRACES_ENABLED=true
# Grafana Tempo OTLP endpoint (HTTP)
HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Optional: Custom service name
HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-api
# Optional: Deployment environment
HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=development
```
## View Data
### Traces
1. Open http://localhost:3000
2. Go to **Explore** (compass icon)
3. Select **Tempo** as data source
4. Click "Search" to see recent traces
### Metrics & Dashboards
1. Open http://localhost:3000
2. Go to **Dashboards** (dashboard icon)
3. Browse the Hindsight folder
### Raw Metrics
- Prometheus metrics: http://localhost:8888/metrics
- PromQL queries: Explore → Prometheus
## Ports
| Port | Service |
|------|---------|
| 3000 | Grafana UI |
| 4317 | OTLP gRPC endpoint |
| 4318 | OTLP HTTP endpoint |
## Stop
```bash
cd scripts/dev/monitoring && docker-compose down
```
## Architecture
- **Single Container**: Grafana LGTM (~515MB) provides all observability components
- **Auto-provisioned Dashboards**: Dashboards from `monitoring/grafana/dashboards/` are automatically loaded
- **Prometheus Scraping**: Configured to scrape Hindsight API at `host.docker.internal:8888/metrics` every 5 seconds
- **Network**: Uses `hindsight-network` (shared with API for future service-to-service tracing)
@@ -0,0 +1,35 @@
services:
grafana-lgtm:
image: grafana/otel-lgtm:latest
container_name: hindsight-monitoring
ports:
# Grafana UI
- "3000:3000"
# OTLP gRPC (traces)
- "4317:4317"
# OTLP HTTP (traces)
- "4318:4318"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
volumes:
# Mount Prometheus config for scraping Hindsight API metrics (override LGTM default)
- ./prometheus.yml:/otel-lgtm/prometheus.yaml:ro
# Mount Hindsight dashboards to LGTM directory (where default dashboards are)
- ../../../monitoring/grafana/dashboards/hindsight-operations.json:/otel-lgtm/hindsight-operations.json:ro
- ../../../monitoring/grafana/dashboards/hindsight-llm.json:/otel-lgtm/hindsight-llm.json:ro
- ../../../monitoring/grafana/dashboards/hindsight-api-service.json:/otel-lgtm/hindsight-api-service.json:ro
# Mount custom dashboard provisioning config that includes Hindsight dashboards
- ./grafana-dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/grafana-dashboards.yaml:ro
restart: unless-stopped
networks:
- hindsight
extra_hosts:
# Allow container to reach host services (Hindsight API on localhost:8888)
- "host.docker.internal:host-gateway"
networks:
hindsight:
name: hindsight-network
external: true
@@ -0,0 +1,36 @@
apiVersion: 1
providers:
# Default LGTM dashboards
- name: "RED Metrics (classic histogram)"
type: file
options:
path: /otel-lgtm/grafana-dashboard-red-metrics-classic.json
foldersFromFilesStructure: false
- name: "RED Metrics (exponential/native histogram)"
type: file
options:
path: /otel-lgtm/grafana-dashboard-red-metrics-native.json
foldersFromFilesStructure: false
- name: "JVM Metrics"
type: file
options:
path: /otel-lgtm/grafana-dashboard-jvm-metrics.json
foldersFromFilesStructure: false
# Hindsight dashboards
- name: "Hindsight Operations"
type: file
options:
path: /otel-lgtm/hindsight-operations.json
foldersFromFilesStructure: false
- name: "Hindsight LLM"
type: file
options:
path: /otel-lgtm/hindsight-llm.json
foldersFromFilesStructure: false
- name: "Hindsight API Service"
type: file
options:
path: /otel-lgtm/hindsight-api-service.json
foldersFromFilesStructure: false
+30
View File
@@ -0,0 +1,30 @@
# Prometheus configuration for Grafana LGTM with Hindsight API scraping
---
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_native_histograms: true
# OTLP receiver configuration (from LGTM default)
otlp:
keep_identifying_resource_attributes: true
promote_resource_attributes:
- service.instance.id
- service.name
- service.namespace
- service.version
- deployment.environment
- deployment.environment.name
- host.name
storage:
tsdb:
out_of_order_time_window: 10m
# Scrape configs for pulling metrics from Hindsight API
scrape_configs:
- job_name: 'hindsight-api'
static_configs:
- targets: ['host.docker.internal:8888']
metrics_path: '/metrics'
scrape_interval: 5s
+34 -202
View File
@@ -1,222 +1,54 @@
#!/bin/bash
set -e
# Script to start Prometheus and Grafana for Hindsight metrics
# This provides a single command for the full monitoring stack
# Script to start the Hindsight monitoring stack with Grafana LGTM
# Provides traces (Tempo), metrics (Prometheus/Mimir), logs (Loki), and dashboards (Grafana)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
MONITORING_DATA_DIR="$PROJECT_ROOT/.monitoring"
API_PORT="${API_PORT:-8888}"
PROMETHEUS_PORT="${PROMETHEUS_PORT:-8889}"
GRAFANA_PORT="${GRAFANA_PORT:-8890}"
# Versions
PROMETHEUS_VERSION="2.48.0"
GRAFANA_VERSION="10.2.2"
# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
darwin) OS_NAME="darwin" ;;
linux) OS_NAME="linux" ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
case "$ARCH" in
x86_64) ARCH_NAME="amd64" ;;
arm64|aarch64) ARCH_NAME="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# Prometheus paths
PROMETHEUS_DIR="$MONITORING_DATA_DIR/prometheus"
PROMETHEUS_ARCHIVE="prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
PROMETHEUS_URL="https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/${PROMETHEUS_ARCHIVE}"
PROMETHEUS_BIN="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/prometheus"
# Grafana paths
GRAFANA_DIR="$MONITORING_DATA_DIR/grafana"
GRAFANA_ARCHIVE="grafana-${GRAFANA_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
GRAFANA_URL="https://dl.grafana.com/oss/release/${GRAFANA_ARCHIVE}"
GRAFANA_HOME="$GRAFANA_DIR/grafana-v${GRAFANA_VERSION}"
GRAFANA_BIN="$GRAFANA_HOME/bin/grafana"
# Cleanup function
cleanup() {
echo ""
echo "Shutting down monitoring stack..."
if [ -n "$PROM_PID" ] && kill -0 "$PROM_PID" 2>/dev/null; then
kill "$PROM_PID" 2>/dev/null || true
fi
if [ -n "$GRAFANA_PID" ] && kill -0 "$GRAFANA_PID" 2>/dev/null; then
kill "$GRAFANA_PID" 2>/dev/null || true
fi
echo "Monitoring stack stopped"
exit 0
}
trap cleanup SIGINT SIGTERM
# Download Prometheus if needed
if [ ! -f "$PROMETHEUS_BIN" ]; then
echo "Downloading Prometheus ${PROMETHEUS_VERSION}..."
mkdir -p "$PROMETHEUS_DIR"
cd "$PROMETHEUS_DIR"
curl -sL -o "$PROMETHEUS_ARCHIVE" "$PROMETHEUS_URL"
tar xzf "$PROMETHEUS_ARCHIVE"
rm "$PROMETHEUS_ARCHIVE"
echo "Prometheus ready"
fi
# Download Grafana if needed
if [ ! -f "$GRAFANA_BIN" ]; then
echo "Downloading Grafana ${GRAFANA_VERSION}..."
mkdir -p "$GRAFANA_DIR"
cd "$GRAFANA_DIR"
curl -sL -o "$GRAFANA_ARCHIVE" "$GRAFANA_URL"
tar xzf "$GRAFANA_ARCHIVE"
rm "$GRAFANA_ARCHIVE"
echo "Grafana ready"
fi
# Create Prometheus config
mkdir -p "$PROMETHEUS_DIR"
cat > "$PROMETHEUS_DIR/prometheus.yml" <<EOF
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: 'hindsight-api'
scrape_interval: 5s
static_configs:
- targets: ['localhost:$API_PORT']
metrics_path: '/metrics'
EOF
# Create Grafana provisioning directories
GRAFANA_PROV_DIR="$GRAFANA_DIR/provisioning"
mkdir -p "$GRAFANA_PROV_DIR/datasources"
mkdir -p "$GRAFANA_PROV_DIR/dashboards"
mkdir -p "$GRAFANA_DIR/dashboards"
mkdir -p "$GRAFANA_DIR/data"
# Copy dashboards from project root monitoring directory
cp "$PROJECT_ROOT/monitoring/grafana/dashboards/"*.json "$GRAFANA_DIR/dashboards/"
# Create Grafana datasource config
cat > "$GRAFANA_PROV_DIR/datasources/prometheus.yaml" <<EOF
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://localhost:$PROMETHEUS_PORT
isDefault: true
editable: false
uid: prometheus
EOF
# Create Grafana dashboard provisioning config
cat > "$GRAFANA_PROV_DIR/dashboards/dashboards.yaml" <<EOF
apiVersion: 1
providers:
- name: 'Hindsight'
orgId: 1
folder: 'Hindsight'
folderUid: 'hindsight'
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: $GRAFANA_DIR/dashboards
EOF
# Create Grafana config
cat > "$GRAFANA_DIR/grafana.ini" <<EOF
[server]
http_port = $GRAFANA_PORT
root_url = http://localhost:$GRAFANA_PORT
[security]
admin_user = admin
admin_password = admin
disable_initial_admin_creation = false
[auth.anonymous]
enabled = true
org_name = Main Org.
org_role = Viewer
[paths]
data = $GRAFANA_DIR/data
logs = $GRAFANA_DIR/logs
plugins = $GRAFANA_DIR/plugins
provisioning = $GRAFANA_PROV_DIR
[log]
mode = console
level = warn
[dashboards]
default_home_dashboard_path = $GRAFANA_DIR/dashboards/hindsight-operations.json
EOF
cd "$SCRIPT_DIR"
echo ""
echo "=================================="
echo " Hindsight Monitoring Stack"
echo "=================================="
echo "🚀 Starting Hindsight Monitoring Stack (Grafana LGTM)"
echo ""
echo " Grafana: http://localhost:$GRAFANA_PORT"
echo " Prometheus: http://localhost:$PROMETHEUS_PORT"
echo " API Metrics: http://localhost:$API_PORT/metrics"
echo ""
echo " Dashboards:"
echo " - Hindsight Operations"
echo " - Hindsight LLM Metrics"
echo " - Hindsight API Service"
echo ""
echo "=================================="
echo "This provides:"
echo " • OpenTelemetry traces (Tempo)"
echo " Metrics (Prometheus/Mimir)"
echo " • Logs (Loki)"
echo " Dashboards (Grafana)"
echo ""
# Check if API is running
if ! curl -s "http://localhost:$API_PORT/metrics" > /dev/null 2>&1; then
echo "WARNING: Hindsight API not detected at localhost:$API_PORT"
echo " Start the API first: ./scripts/dev/start-api.sh"
echo "⚠️ WARNING: Hindsight API not detected at localhost:$API_PORT"
echo " Start the API first: ./scripts/dev/start-api.sh"
echo ""
fi
# Start Prometheus in background
cd "$(dirname "$PROMETHEUS_BIN")"
"$PROMETHEUS_BIN" \
--config.file="$PROMETHEUS_DIR/prometheus.yml" \
--storage.tsdb.path="$PROMETHEUS_DIR/data" \
--web.console.templates="$(dirname "$PROMETHEUS_BIN")/consoles" \
--web.console.libraries="$(dirname "$PROMETHEUS_BIN")/console_libraries" \
--web.listen-address="0.0.0.0:$PROMETHEUS_PORT" \
--web.enable-lifecycle \
--log.level=warn &
PROM_PID=$!
# Start Grafana in background
cd "$GRAFANA_HOME"
"$GRAFANA_BIN" server \
--homepath="$GRAFANA_HOME" \
--config="$GRAFANA_DIR/grafana.ini" &
GRAFANA_PID=$!
echo "Monitoring stack running. Press Ctrl+C to stop."
echo "Access Grafana UI: http://localhost:3000"
echo " (no login required for dev - anonymous admin enabled)"
echo ""
echo "Dashboards available:"
echo " • Hindsight Operations"
echo " • Hindsight LLM Metrics"
echo " • Hindsight API Service"
echo ""
echo "Configure Hindsight API for tracing:"
echo " export HINDSIGHT_API_OTEL_TRACES_ENABLED=true"
echo " export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318"
echo ""
echo "OTLP Endpoints:"
echo " • HTTP: http://localhost:4318"
echo " • gRPC: http://localhost:4317"
echo ""
echo "View:"
echo " • Traces: http://localhost:3000 → Explore → Tempo"
echo " • Metrics: http://localhost:3000 → Dashboards"
echo " • Raw Metrics: http://localhost:$API_PORT/metrics"
echo ""
echo "Press Ctrl+C to stop"
echo ""
# Wait for processes
wait "$PROM_PID" "$GRAFANA_PID" 2>/dev/null || true
# If we get here, clean up
cleanup
docker-compose up
+280
View File
@@ -0,0 +1,280 @@
#!/bin/bash
set -e
# Generate agent skill from Hindsight documentation
# Converts docs/ to skills/hindsight-docs/ for AI agent consumption
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DOCS_DIR="$ROOT_DIR/hindsight-docs/docs"
EXAMPLES_DIR="$ROOT_DIR/hindsight-docs/examples"
SKILL_DIR="$ROOT_DIR/skills/hindsight-docs"
REFS_DIR="$SKILL_DIR/references"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_info "Generating Hindsight documentation skill..."
# Clean and recreate skill directory
rm -rf "$SKILL_DIR"
mkdir -p "$REFS_DIR"
# Process markdown files
process_file() {
local src_file="$1"
local rel_path="${src_file#$DOCS_DIR/}"
local dest_file="$REFS_DIR/$rel_path"
# Create destination directory
mkdir -p "$(dirname "$dest_file")"
# Process the file
if [[ "$src_file" == *.mdx ]]; then
# Change .mdx to .md
dest_file="${dest_file%.mdx}.md"
print_info "Converting: $rel_path"
convert_mdx_to_md "$src_file" "$dest_file"
else
print_info "Copying: $rel_path"
cp "$src_file" "$dest_file"
fi
}
# Convert MDX to Markdown by:
# 1. Removing import statements
# 2. Replacing JSX components with markdown equivalents
# 3. Inlining code examples from example files
convert_mdx_to_md() {
local src="$1"
local dest="$2"
# Use Python for more robust processing
python3 - "$src" "$dest" "$EXAMPLES_DIR" <<'PYTHON'
import sys
import re
from pathlib import Path
src_file = Path(sys.argv[1])
dest_file = Path(sys.argv[2])
examples_dir = Path(sys.argv[3])
content = src_file.read_text()
original_content = content # Keep original for import searches
# Remove frontmatter
content = re.sub(r'^---\n.*?\n---\n', '', content, flags=re.DOTALL)
# Remove import statements
content = re.sub(r'^import .*?;?\n', '', content, flags=re.MULTILINE)
# Extract code example inlining: <CodeSnippet code={varName} section="..." language="..." />
# Replace with actual code content from examples directory
def inline_code_snippet(match):
var_name = match.group(1)
section = match.group(2)
language = match.group(3)
# Find the import that loaded this variable - search in original content
import_match = re.search(rf"import {var_name} from '!!raw-loader!@site/(.+?)';", original_content)
if not import_match:
return f"```{language}\n# Could not find import for: {var_name}\n```"
# Load the example file
# The import path is like "examples/api/quickstart.py", but examples_dir already points to examples/
example_rel_path = import_match.group(1)
# Strip "examples/" prefix if present since examples_dir already includes it
if example_rel_path.startswith("examples/"):
example_rel_path = example_rel_path[len("examples/"):]
example_path = examples_dir / example_rel_path
if not example_path.exists():
return f"```{language}\n# Example file not found: {example_path}\n```"
example_content = example_path.read_text()
# Extract section if specified - examples use comment markers like # [docs:section] or // [docs:section]
if section:
# Try various comment formats: #, //, etc.
# Pattern: (comment) [docs:section] ... (comment) [/docs:section]
section_pattern = rf"(?:^|\n)(?:#|//)\s*\[docs:{re.escape(section)}\]\n(.*?)\n(?:#|//)\s*\[/docs:{re.escape(section)}\]"
section_match = re.search(section_pattern, example_content, re.DOTALL | re.MULTILINE)
if not section_match:
# Try alternative # section-start / # section-end format
section_pattern = rf"(?:^|\n)#\s*{re.escape(section)}-start\n(.*?)\n#\s*{re.escape(section)}-end"
section_match = re.search(section_pattern, example_content, re.DOTALL | re.MULTILINE)
if section_match:
example_content = section_match.group(1).strip()
else:
return f"```{language}\n# Section '{section}' not found in {example_rel_path}\n```"
return f"```{language}\n{example_content}\n```"
content = re.sub(
r'<CodeSnippet code=\{(\w+)\} section="([^"]+)" language="([^"]+)" />',
inline_code_snippet,
content
)
# Convert <Tabs> to markdown sections
# Replace <Tabs> ... </Tabs> with markdown headers
content = re.sub(r'<Tabs>\s*', '', content)
content = re.sub(r'</Tabs>\s*', '', content)
# Convert <TabItem value="x" label="Y"> to ### Y
content = re.sub(r'<TabItem value="[^"]*" label="([^"]+)">', r'### \1\n', content)
content = re.sub(r'</TabItem>', '', content)
# Convert :::tip, :::warning, :::note to markdown blockquotes
content = re.sub(r':::tip (.+?)\n', r'> **💡 \1**\n> \n', content)
content = re.sub(r':::warning (.+?)\n', r'> **⚠️ \1**\n> \n', content)
content = re.sub(r':::note (.+?)\n', r'> **📝 \1**\n> \n', content)
content = re.sub(r':::\s*\n', '', content)
# Clean up extra blank lines
content = re.sub(r'\n{3,}', '\n\n', content)
dest_file.write_text(content)
PYTHON
}
# Find and process all markdown files
print_info "Processing documentation files..."
find "$DOCS_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) | while read -r file; do
process_file "$file"
done
# Generate SKILL.md
print_info "Generating SKILL.md..."
cat > "$SKILL_DIR/SKILL.md" <<'EOF'
---
name: hindsight-docs
description: Complete Hindsight documentation for AI agents. Use this to learn about Hindsight architecture, APIs, configuration, and best practices.
---
# Hindsight Documentation Skill
Complete technical documentation for Hindsight - a biomimetic memory system for AI agents.
## When to Use This Skill
Use this skill when you need to:
- Understand Hindsight architecture and core concepts
- Learn about retain/recall/reflect operations
- Configure memory banks and dispositions
- Set up the Hindsight API server (Docker, Kubernetes, pip)
- Integrate with Python/Node.js/Rust SDKs
- Understand retrieval strategies (semantic, BM25, graph, temporal)
- Debug issues or optimize performance
- Review API endpoints and parameters
- Find cookbook examples and recipes
## Documentation Structure
All documentation is in `references/` organized by category:
```
references/
├── developer/
│ ├── api/ # Core operations: retain, recall, reflect, memory banks
│ └── *.md # Architecture, configuration, deployment, performance
├── sdks/
│ ├── *.md # Python, Node.js, CLI, embedded
│ └── integrations/ # LiteLLM, AI SDK, OpenClaw, MCP, skills
└── cookbook/
├── recipes/ # Usage patterns and examples
└── applications/ # Full application demos
```
## How to Find Documentation
### 1. Find Files by Pattern (use Glob tool)
```bash
# Core API operations
references/developer/api/*.md
# SDK documentation
references/sdks/*.md
references/sdks/integrations/*.md
# Cookbook examples
references/cookbook/recipes/*.md
references/cookbook/applications/*.md
# Find specific topics
references/**/configuration.md
references/**/*python*.md
references/**/*deployment*.md
```
### 2. Search Content (use Grep tool)
```bash
# Search for concepts
pattern: "disposition" # Memory bank configuration
pattern: "graph retrieval" # Graph-based search
pattern: "helm install" # Kubernetes deployment
pattern: "document_id" # Document management
pattern: "HINDSIGHT_API_" # Environment variables
# Search in specific areas
path: references/developer/api/
pattern: "POST /v1" # Find API endpoints
path: references/cookbook/
pattern: "def |async def " # Find Python examples
```
### 3. Read Full Documentation (use Read tool)
```
references/developer/api/retain.md
references/sdks/python.md
references/cookbook/recipes/per-user-memory.md
```
## Key Concepts
- **Memory Banks**: Isolated memory stores (one per user/agent)
- **Retain**: Store memories (auto-extracts facts/entities/relationships)
- **Recall**: Retrieve memories (4 parallel strategies: semantic, BM25, graph, temporal)
- **Reflect**: Disposition-aware reasoning using memories
- **document_id**: Groups messages in a conversation (upsert on same ID)
- **Dispositions**: Skepticism, literalism, empathy traits (1-5) affecting reflect
- **Mental Models**: Consolidated knowledge synthesized from facts
## Notes
- Code examples are inlined from working examples
- Configuration uses `HINDSIGHT_API_*` environment variables
- Database migrations run automatically on startup
- Multi-bank queries require client-side orchestration
- Use `document_id` for conversation evolution (same ID = upsert)
---
**Auto-generated** from `hindsight-docs/docs/`. Run `./scripts/generate-docs-skill.sh` to update.
EOF
print_info "✓ Generated skill at: $SKILL_DIR"
print_info "✓ Documentation files: $(find "$REFS_DIR" -type f | wc -l | tr -d ' ')"
print_info "✓ SKILL.md created with search guidance"
echo ""
print_info "Usage:"
echo " - Agents can use Glob to find files: references/developer/api/*.md"
echo " - Agents can use Grep to search content: pattern='disposition'"
echo " - Agents can use Read to view full docs"
+5
View File
@@ -120,6 +120,11 @@ else
print_warn " To update ${MAJOR_MINOR} docs, use patch releases (e.g., ${MAJOR_MINOR}.1)"
fi
# Generate documentation skill for AI agents
echo ""
print_info "Generating documentation skill for AI agents..."
"$SCRIPT_DIR/generate-docs-skill.sh"
echo ""
print_info "Next steps:"
echo " 1. Review changes: git diff $DOCS_DIR"
+108
View File
@@ -0,0 +1,108 @@
---
name: hindsight-docs
description: Complete Hindsight documentation for AI agents. Use this to learn about Hindsight architecture, APIs, configuration, and best practices.
---
# Hindsight Documentation Skill
Complete technical documentation for Hindsight - a biomimetic memory system for AI agents.
## When to Use This Skill
Use this skill when you need to:
- Understand Hindsight architecture and core concepts
- Learn about retain/recall/reflect operations
- Configure memory banks and dispositions
- Set up the Hindsight API server (Docker, Kubernetes, pip)
- Integrate with Python/Node.js/Rust SDKs
- Understand retrieval strategies (semantic, BM25, graph, temporal)
- Debug issues or optimize performance
- Review API endpoints and parameters
- Find cookbook examples and recipes
## Documentation Structure
All documentation is in `references/` organized by category:
```
references/
├── developer/
│ ├── api/ # Core operations: retain, recall, reflect, memory banks
│ └── *.md # Architecture, configuration, deployment, performance
├── sdks/
│ ├── *.md # Python, Node.js, CLI, embedded
│ └── integrations/ # LiteLLM, AI SDK, OpenClaw, MCP, skills
└── cookbook/
├── recipes/ # Usage patterns and examples
└── applications/ # Full application demos
```
## How to Find Documentation
### 1. Find Files by Pattern (use Glob tool)
```bash
# Core API operations
references/developer/api/*.md
# SDK documentation
references/sdks/*.md
references/sdks/integrations/*.md
# Cookbook examples
references/cookbook/recipes/*.md
references/cookbook/applications/*.md
# Find specific topics
references/**/configuration.md
references/**/*python*.md
references/**/*deployment*.md
```
### 2. Search Content (use Grep tool)
```bash
# Search for concepts
pattern: "disposition" # Memory bank configuration
pattern: "graph retrieval" # Graph-based search
pattern: "helm install" # Kubernetes deployment
pattern: "document_id" # Document management
pattern: "HINDSIGHT_API_" # Environment variables
# Search in specific areas
path: references/developer/api/
pattern: "POST /v1" # Find API endpoints
path: references/cookbook/
pattern: "def |async def " # Find Python examples
```
### 3. Read Full Documentation (use Read tool)
```
references/developer/api/retain.md
references/sdks/python.md
references/cookbook/recipes/per-user-memory.md
```
## Key Concepts
- **Memory Banks**: Isolated memory stores (one per user/agent)
- **Retain**: Store memories (auto-extracts facts/entities/relationships)
- **Recall**: Retrieve memories (4 parallel strategies: semantic, BM25, graph, temporal)
- **Reflect**: Disposition-aware reasoning using memories
- **document_id**: Groups messages in a conversation (upsert on same ID)
- **Dispositions**: Skepticism, literalism, empathy traits (1-5) affecting reflect
- **Mental Models**: Consolidated knowledge synthesized from facts
## Notes
- Code examples are inlined from working examples
- Configuration uses `HINDSIGHT_API_*` environment variables
- Database migrations run automatically on startup
- Multi-bank queries require client-side orchestration
- Use `document_id` for conversation evolution (same ID = upsert)
---
**Auto-generated** from `hindsight-docs/docs/`. Run `./scripts/generate-docs-skill.sh` to update.
@@ -0,0 +1,120 @@
---
sidebar_position: 1
---
# Chat Memory App
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/chat-memory)
:::
A demo chat application that uses Groq's `qwen/qwen3-32b` model with Hindsight for persistent per-user memory.
## Features
- 🧠 **Persistent Memory**: Each user gets their own memory bank that remembers conversations
- 🚀 **Fast AI**: Powered by Groq's high-speed inference
- 🎯 **Per-User Context**: Isolated memory per user with automatic context retrieval
- 💬 **Real-time Chat**: Instant responses with memory-augmented context
## Setup
### 1. Start Hindsight API
First, start the Hindsight API server using Docker:
```bash
export GROQ_API_KEY=your_groq_api_key_here
# Start Hindsight with Groq as the LLM provider
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=groq \
-e HINDSIGHT_API_LLM_API_KEY=$GROQ_API_KEY \
-e HINDSIGHT_API_LLM_MODEL="openai/gpt-oss-20b" \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API**: http://localhost:8888
- **Control Plane UI**: http://localhost:9999
### 2. Configure Environment
Copy your Groq API key to the environment file:
```bash
# Update .env.local with your Groq API key
echo "GROQ_API_KEY=your_groq_api_key_here" > .env.local
echo "HINDSIGHT_API_URL=http://localhost:8888" >> .env.local
```
If you don't have one, you can get a free Groq API key here: https://console.groq.com/home
### 3. Install Dependencies
```bash
npm install
```
### 4. Run the App
```bash
npm run dev
```
Open http://localhost:3000 in your browser.
## How It Works
1. **User Identity**: Each browser session gets a unique user ID
2. **Memory Bank Creation**: First message creates a personal memory bank in Hindsight
3. **Context Retrieval**: Before responding, relevant memories are retrieved
4. **Memory Augmented Response**: Groq generates responses with memory context
5. **Conversation Storage**: Each conversation is stored for future context
## Architecture
```
User Message
Next.js API Route (/api/chat)
Hindsight.recall() → Get relevant memories
Groq API → Generate response with memory context
Hindsight.retain() → Store conversation
Response to User
```
## Memory Bank Structure
Each user gets their own isolated memory bank with:
- **Name**: "Chat Memory for [userId]"
- **Background**: Conversational AI assistant context
- **Disposition**: Empathetic (4), Low Skepticism (2), Balanced Literalism (3)
## Try It Out
1. **First Conversation**: Tell the assistant about yourself
- "Hi! I'm a software engineer from San Francisco. I love Python and machine learning."
2. **Second Conversation**: Ask what it remembers
- "What do you know about me?"
- "What programming languages do I like?"
3. **Context Building**: Continue sharing preferences
- "I prefer VS Code over other editors"
- "I'm working on a React project"
4. **Memory Verification**: Visit the Hindsight Control Plane at http://localhost:9999 to see stored memories
## Development
- **Groq Model**: Uses `qwen/qwen3-32b` for fast, high-quality responses
- **Memory Storage**: Automatic conversation retention with context categorization
- **Memory Retrieval**: Semantic search with 2048 token budget for relevant context
@@ -0,0 +1,145 @@
---
sidebar_position: 2
---
# Deliveryman Demo
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/deliveryman-demo)
:::
A delivery agent simulation that demonstrates Hindsight's long-term memory capabilities. An AI agent navigates a multi-building office complex to deliver packages, learning employee locations and optimal paths over time through mental models.
## Prerequisites
- Python 3.11+
- Node.js 18+
- [uv](https://docs.astral.sh/uv/) (Python package manager)
## Setup (Fresh Environment)
### 1. Clone Repositories
```bash
# Clone Hindsight (memory engine)
git clone https://github.com/anthropics/hindsight.git
# Clone the cookbook (contains this demo)
git clone https://github.com/anthropics/hindsight-cookbook.git
```
### 2. Start Hindsight API
```bash
cd hindsight
cp .env.example .env
```
Edit `.env` with your LLM configuration:
```bash
HINDSIGHT_API_LLM_PROVIDER=groq
HINDSIGHT_API_LLM_API_KEY=<your-groq-api-key>
HINDSIGHT_API_LLM_MODEL=openai/gpt-oss-120b
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_ENABLE_OBSERVATIONS=true
# Retain extraction settings (improves employee/location extraction)
HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS="Delivery agent. Remember employee locations, building layout, and optimal paths."
# Embedded database storage
PG0_DATA_DIR=/tmp/hindsight-data
```
Start the API:
```bash
./scripts/dev/start-api.sh
# Runs on http://localhost:8888
```
### 3. Start Hindsight Control Plane (Optional)
The control plane provides a web UI for inspecting memory banks, facts, and mental models.
```bash
cd hindsight
./scripts/dev/start-control-plane.sh
# Runs on a dynamic port (check terminal output)
```
### 4. Start Demo Backend
```bash
cd hindsight-cookbook/deliveryman-demo/backend
# Create virtual environment and install dependencies
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Create `backend/.env`:
```bash
OPENAI_API_KEY=<your-openai-api-key>
GROQ_API_KEY=<your-groq-api-key>
HINDSIGHT_API_URL=http://localhost:8888
LLM_MODEL=openai/gpt-4o
```
Start the backend:
```bash
./run.sh
# Or manually:
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --ws wsproto --reload
```
**Note:** The `--ws wsproto` flag is required for WebSocket support. Without it, connections will fail with error 1006.
### 5. Start Demo Frontend
```bash
cd hindsight-cookbook/deliveryman-demo/frontend
npm install
npm run dev
# Runs on http://localhost:5173
```
### 6. Open the Demo
Navigate to http://localhost:5173 in your browser.
## How It Works
1. The agent receives a delivery task (e.g., "Deliver Package #3954 to Victor Huang")
2. It navigates a multi-building complex with floors, elevators, and sky bridges
3. Along the way it encounters employees and learns their locations
4. After each delivery, the conversation is sent to Hindsight via the **retain** API
5. Hindsight extracts facts (employee locations, building layout) and builds **mental models**
6. On subsequent deliveries, the agent queries Hindsight to recall what it learned
## Architecture
```
Browser (5173) → Frontend (React + Phaser)
↓ WebSocket
Backend (8000) → FastAPI + Delivery Agent
↓ HTTP
Hindsight API (8888) → Memory Engine + PostgreSQL
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| WebSocket error 1006 | Restart backend with `--ws wsproto` flag |
| Mental models missing employees | Check `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom` is set |
| Hindsight connection refused | Verify Hindsight API is running on port 8888 |
| Frontend shows "Disconnected" | Check backend is running on port 8000 |
@@ -0,0 +1,206 @@
---
sidebar_position: 3
---
# Memory Approaches Comparison Demo
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/hindsight-litellm-demo)
:::
Interactive Streamlit app comparing three memory approaches for LLM applications:
1. **No Memory** - Each query is independent (baseline)
2. **Full Conversation History** - Pass entire conversation (truncated to simulate context limits)
3. **Hindsight Memory** - Intelligent semantic memory retrieval
This demo showcases how Hindsight's semantic memory outperforms traditional approaches, especially as conversations grow longer.
## Quick Start
```bash
# 1. Set your OpenAI API key
export OPENAI_API_KEY=your-key
# 2. Start Hindsight server
docker run -d -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
ghcr.io/vectorize-io/hindsight:latest
# 3. Run the demo
./run.sh
```
Then open http://localhost:8501 in your browser.
## What This Demo Shows
### The Problem with Traditional Approaches
| Approach | How it Works | Limitation |
|----------|--------------|------------|
| **No Memory** | Each query standalone | Forgets everything between messages |
| **Full History** | Pass all messages to LLM | Token limits cause truncation - loses early context |
| **Hindsight** | Semantic retrieval of relevant facts | Retrieves what's relevant regardless of when it was said |
### Key Insight
After 5-10 messages, watch the **Full Conversation History** column start losing early context due to truncation (artificially set to 4 messages to demonstrate this quickly). Meanwhile, **Hindsight Memory** can still recall facts from the beginning because it uses semantic retrieval rather than sequential history.
## Testing the Demo
1. **Introduce yourself**:
- "Hi, I'm Sarah, a data scientist at Netflix"
- "I prefer Python and love machine learning"
2. **Have several exchanges** about different topics
3. **Test recall**:
- "What programming language should I use?"
- "What do you know about me?"
Watch how the three columns respond differently as the conversation grows.
## Features
- **Side-by-side comparison** of all three approaches
- **Debug panels** showing what context each approach uses
- **Memory explorer** to search Hindsight memories directly
- **Configurable settings** for history truncation, max memories, etc.
- **Multi-provider support** via LiteLLM (OpenAI, Anthropic, Groq)
## Prerequisites
- Python 3.10+
- Hindsight server running (Docker recommended)
- At least one LLM API key (OpenAI recommended)
## Setup
### Using run.sh (Recommended)
```bash
# Set API key
export OPENAI_API_KEY=your-key
# Start Hindsight, then run:
./run.sh
```
The script will check and install dependencies automatically.
### Manual Setup
```bash
# Install dependencies
pip install streamlit litellm
# Install Hindsight packages
pip install hindsight-client hindsight-litellm
# Run the app
streamlit run app.py
```
### Starting Hindsight Server
```bash
docker run -d -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
ghcr.io/vectorize-io/hindsight:latest
# Verify it's running
curl http://localhost:8888/health
```
## Configuration
### Sidebar Options
**Model Selection:**
- Provider: OpenAI, Anthropic, Groq
- Model: Various models per provider
- Custom model ID support
**Full History Config:**
- Max Messages to Keep (default: 4 to demonstrate truncation)
**Hindsight Config:**
- API URL (default: http://localhost:8888)
- Bank ID and Entity ID for memory isolation
- Max Memories to retrieve
- Recall Budget (low/mid/high)
**Generation Settings:**
- Temperature
- Max Tokens
- System Prompt
## Supported Models
### OpenAI
- gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-4, gpt-3.5-turbo
### Anthropic
- claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022
- claude-3-opus-20240229, claude-3-sonnet-20240229
### Groq
- groq/llama-3.1-70b-versatile, groq/llama-3.1-8b-instant
- groq/mixtral-8x7b-32768
## Environment Variables
```bash
# Required
export OPENAI_API_KEY=sk-...
# Optional (for other providers)
export ANTHROPIC_API_KEY=sk-ant-...
export GROQ_API_KEY=gsk_...
# Optional
export HINDSIGHT_URL=http://localhost:8888
```
## Troubleshooting
### Hindsight server not responding
```bash
# Check if running
curl http://localhost:8888/health
# Start with Docker
docker run -d -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
ghcr.io/vectorize-io/hindsight:latest
```
### hindsight-litellm not installed
```bash
pip install hindsight-litellm
```
### API key errors
Make sure the appropriate API key is set:
```bash
export OPENAI_API_KEY=your-key
```
## Related
- [Hindsight](https://github.com/vectorize-io/hindsight) - Memory infrastructure for AI applications
- [hindsight-litellm](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/litellm) - LiteLLM integration package
## License
MIT
@@ -0,0 +1,123 @@
---
sidebar_position: 4
---
# Tool Learning Demo
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/hindsight-tool-learning-demo)
:::
An interactive Streamlit demo showing how Hindsight helps LLMs learn which tool to use when tool names are ambiguous.
## The Problem
When building AI agents with tool/function calling, tool names and descriptions aren't always clear. An LLM might randomly select between similarly-named tools, leading to incorrect behavior.
## The Scenario
This demo simulates a **customer service routing system** with two channels:
| Tool | Description (What the LLM sees) | Actual Purpose (Hidden) |
|------|--------------------------------|------------------------|
| `route_to_channel_alpha` | "Routes to channel Alpha for appropriate request types" | Financial issues (refunds, billing, payments) |
| `route_to_channel_omega` | "Routes to channel Omega for appropriate request types" | Technical issues (bugs, features, errors) |
The descriptions are **intentionally vague**! Without prior knowledge, the LLM must guess which channel handles what.
## The Solution: Learning with Hindsight
With Hindsight memory:
1. **Store routing feedback** about which channel handles which request type
2. **Retrieve learned knowledge** when making routing decisions
3. **Consistently route correctly** based on past experience
## Quick Start
### Prerequisites
1. **Hindsight Server** running (Docker):
```bash
docker run -d -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/vectorize-io/hindsight:latest
```
2. **OpenAI API Key**:
```bash
export OPENAI_API_KEY=your-key-here
```
### Run the Demo
```bash
./run.sh
```
Or manually:
```bash
pip install -r requirements.txt
streamlit run app.py
```
## How to Use the Demo
### Step 1: Test Without Memory (Baseline)
1. Select a **Financial Request** (e.g., "I need a refund...")
2. Click **Route Request**
3. Observe: The "Without Hindsight" column may route incorrectly
### Step 2: Route First Customer and Learn
1. Route a customer → Both LLMs route simultaneously
2. Feedback is automatically stored to Hindsight
3. Wait ~5 seconds for Hindsight to index the memory
### Step 3: Test With Memory
1. Select another request (financial or technical)
2. Click **Route Request**
3. Observe: The "With Hindsight" column should now route correctly!
### Step 4: View Statistics
- See accuracy comparison between "Without Memory" vs "With Hindsight"
- Review test history to see the improvement over time
## Demo Features
- **Side-by-side comparison**: See routing results with and without memory
- **Pre-defined test requests**: Financial and technical scenarios
- **Custom requests**: Enter your own customer requests
- **Memory Explorer**: Query stored routing knowledge directly
- **Live statistics**: Track accuracy improvement
## Key Insight
> Even when tool names and descriptions don't reveal their purpose, Hindsight allows the LLM to **learn from experience** which tool to use for which type of request.
This is especially valuable for:
- Enterprise systems with legacy tool names
- Multi-tenant systems where tools have generic names
- Agents that need to learn organization-specific workflows
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| Model | gpt-4o-mini | LLM model for routing decisions |
| Temperature (No Memory) | 0.7 | Randomness for baseline tests |
| Hindsight API URL | http://localhost:8888 | Hindsight server URL |
## Files
- `app.py` - Main Streamlit application
- `requirements.txt` - Python dependencies
- `run.sh` - Launch script with dependency checking
- `README.md` - This file
@@ -0,0 +1,315 @@
---
sidebar_position: 5
---
# OpenAI Agent + Hindsight Memory Integration
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/openai-fitness-coach)
:::
A fitness coach example demonstrating how to use **OpenAI Agents** with **Hindsight as a memory backend**.
## What This Demonstrates
This example showcases:
- **OpenAI Assistants** handling conversation logic
- **Hindsight** providing sophisticated memory storage & retrieval
- **Function calling** to bridge them together
- **Streaming responses** for real-time interaction (enabled by default)
- **Bidirectional memory** - both user data AND coach observations stored
- **System-level post-processing** - automatic opinion storage for reliability
- **Temporal-semantic memory** queries via function tools
- **Enhanced preference learning** - coach learns and respects user likes/dislikes
- **Real-world integration pattern** for adding memory to AI agents
## Architecture
```
User: "I ran 5K today, don't like tempo runs"
|
OpenAI Assistant
|
Function Call: store_memory(workout + preference)
|
Hindsight API (stores as world/agent)
|
OpenAI Assistant: "What should I focus on?"
|
Function Call: retrieve_memories("workouts and preferences")
|
Hindsight API (returns workouts + preferences)
|
OpenAI Assistant (analyzes, gives advice)
|
Function Call: store_memory(advice as opinion)
|
Hindsight API (stores coach's observation)
|
Personalized Answer
```
## Key Difference from Standard Demo
| Component | Standard Demo | OpenAI Integration |
|-----------|---------------|-------------------|
| **Conversation** | Hindsight `/think` endpoint | OpenAI Assistant API |
| **Memory** | Hindsight (built-in) | Hindsight (via function calling) |
| **LLM** | Configured in Hindsight | OpenAI GPT-4 |
| **Opinion Formation** | Automatic in `/think` | Explicit via `store_memory(type="opinion")` |
| **Best For** | Hindsight-native apps | Integrating memory into existing OpenAI agents |
## Quick Start
### Prerequisites
1. **OpenAI API Key**
```bash
export OPENAI_API_KEY=your_openai_api_key
```
2. **Hindsight API running**
```bash
# Follow Hindsight setup instructions to start the API
# Default: http://localhost:8888
```
3. **Install dependencies**
```bash
pip install openai requests
```
### Run the Conversational Demo
```bash
cd openai-fitness-coach
export OPENAI_API_KEY=your_key_here
python demo_conversational.py
```
The demo showcases:
1. **Natural language workout logging** - Tell the coach what you did conversationally
2. **Preference learning** - Express likes/dislikes and watch the coach adapt
3. **Goal tracking** - Set goals, track progress, achieve milestones
4. **Bidirectional memory** - Both your activities AND coach's advice are stored
5. **Streaming responses** - See responses appear in real-time
6. **7 interactive phases** - From goal setting to achievement recognition
The demo uses a separate agent (`fitness-coach-demo`) to avoid mixing with real data.
## Usage
### Chat with Your Coach
**Interactive mode:**
```bash
python openai_coach.py
```
**Single question:**
```bash
python openai_coach.py "What did I do for training this week?"
```
## How It Works
### 1. Memory Tools (`memory_tools.py`)
Defines function tools that the OpenAI Agent can call:
```python
retrieve_memories(query, fact_types, top_k)
search_workouts(after_date, before_date, workout_type)
get_nutrition_summary(after_date, before_date)
get_user_goals()
get_coach_opinions(about)
```
Each function makes API calls to Hindsight to fetch relevant memories.
### 2. OpenAI Agent (`openai_coach.py`)
Creates an OpenAI Assistant with:
- Fitness coaching instructions
- Access to memory function tools
- Conversation management
When you ask a question:
1. User message is sent to OpenAI Assistant
2. Assistant decides which memory functions to call
3. Functions fetch data from Hindsight
4. Assistant generates response using retrieved context
### 3. Function Calling Flow
```python
# User asks: "What did I run this week?"
# OpenAI Assistant decides to call:
search_workouts(
after_date="2024-11-18",
workout_type="running"
)
# Function retrieves from Hindsight:
{
"results": [
{"text": "User completed 45-minute cardio workout: running..."},
{"text": "User completed 60-minute cardio workout: running..."}
]
}
# OpenAI Assistant generates response:
"This week you've done two runs: a 45-minute run on Monday
and a longer 60-minute run on Wednesday. Great consistency!"
```
## Example Questions
Try asking:
```bash
python openai_coach.py "What does my training look like this week?"
python openai_coach.py "Based on my workouts, should I rest today?"
python openai_coach.py "How is my nutrition supporting my goals?"
python openai_coach.py "What's my progress toward my goal?"
python openai_coach.py "Compare my training this month to last month"
```
The agent will automatically:
1. Identify what memories it needs
2. Call the appropriate function tools
3. Retrieve data from Hindsight
4. Generate a personalized response
## Memory Types Retrieved
The OpenAI Agent can retrieve different memory types from Hindsight:
- **World Facts** (`fact_type: "world"`): Workouts, meals, activities
- **Agent Facts** (`fact_type: "agent"`): Goals, intentions
- **Opinions** (`fact_type: "opinion"`): Coach's observations about patterns
## Customization
### Add New Function Tools
Edit `memory_tools.py` to add new capabilities:
```python
def get_weekly_summary(week_offset: int = 0):
"""Get a summary of a specific week."""
# Implementation
pass
# Add to MEMORY_TOOLS list
MEMORY_TOOLS.append({
"type": "function",
"function": {
"name": "get_weekly_summary",
"description": "Get training summary for a specific week",
# ... parameters
}
})
# Add to FUNCTION_MAP
FUNCTION_MAP["get_weekly_summary"] = get_weekly_summary
```
### Modify Assistant Instructions
Edit `openai_coach.py` to change the coach's personality or behavior:
```python
assistant = client.beta.assistants.create(
name="Your Custom Coach",
instructions="Your custom instructions here...",
model="gpt-4o-mini",
tools=MEMORY_TOOLS
)
```
## Use Cases
This pattern works for any application that needs memory:
1. **Customer Support Agents** - Remember past conversations and issues
2. **Personal Assistants** - Remember preferences, schedules, past decisions
3. **Educational Tutors** - Track learning progress over time
4. **Health Coaches** - Monitor habits, progress, goals (like this example)
5. **Sales Assistants** - Remember customer interactions and preferences
## Integration Pattern
**To add Hindsight memory to your own OpenAI Agent:**
1. Define function tools that call Hindsight API
2. Register them with your OpenAI Assistant
3. Implement function handlers to execute Hindsight queries
4. Let OpenAI Assistant decide when to retrieve memories
The key benefit: **Separation of concerns**
- OpenAI = Conversation logic
- Hindsight = Memory storage, retrieval, temporal queries, entity linking
## When to Use This vs. Standard Hindsight
**Use OpenAI + Hindsight (this example) when:**
- You want OpenAI's conversation capabilities
- You're already using OpenAI Agents
- You want explicit control over when to retrieve memories
- You want to combine Hindsight with other OpenAI features
**Use Hindsight directly when:**
- You want a complete memory-first solution
- You want automatic memory retrieval and opinion formation
- You want to use different LLM providers (not just OpenAI)
- You want the `/think` endpoint's integrated approach
## Learning Points
After running this demo, you'll understand:
1. How to add sophisticated memory to any OpenAI Agent
2. How function calling bridges LLMs and memory systems
3. How temporal-semantic queries work via function tools
4. Real-world pattern for LLM + memory integration
## Core Files
- `demo_conversational.py` - Conversational demo showcasing preference learning and goal tracking
- `openai_coach.py` - OpenAI Assistant wrapper with streaming and memory integration
- `memory_tools.py` - Function calling tools that bridge to Hindsight API
- `.openai_assistant_id` - Saved assistant ID (auto-generated, gitignored)
## Common Issues
**"OPENAI_API_KEY not set"**
```bash
export OPENAI_API_KEY=your_api_key_here
```
**"Agent not found"**
- Make sure the Hindsight fitness-coach agent exists
**"Connection refused"**
- Make sure Hindsight API is running on localhost:8888
## Next Steps
1. Run the demo to see it in action
2. Try chatting with the coach: `python openai_coach.py`
3. Log your own workouts and meals
4. Experiment with different questions
5. Add custom function tools for your use case
---
**Built with:**
- OpenAI Assistants API
- Hindsight (temporal-semantic memory)
- Function calling for integration
@@ -0,0 +1,371 @@
---
sidebar_position: 6
---
# Sanity CMS Blog Memory
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/sanity-blog-memory)
:::
A Hindsight cookbook recipe demonstrating how to sync blog posts from **Sanity CMS** to Hindsight agent memory, enabling semantic search, temporal queries, and AI-powered content insights.
## Features
- **Blog Post Sync**: Automatically sync all blog posts from Sanity to Hindsight
- **Document-based Upsert**: Idempotent syncing with `document_id` - re-running sync updates existing content
- **Semantic Search**: Find related content using natural language queries
- **Temporal Queries**: Ask "What did I write in January 2025?"
- **Reflect for Insights**: Generate AI-powered analysis of your blog content
- **Related Content Discovery**: Power "Related Posts" features with semantic similarity
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Sanity CMS │───────▶│ Sync Script │───────▶│ Hindsight │
│ (Content) │ GROQ │ (TypeScript) │ HTTP │ (Memory) │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ │
│ Your App │
│ - Recall │
│ - Reflect │
│ │
└─────────────────┘
```
## Quick Start
### 1. Start Hindsight
Choose your preferred LLM provider:
**Option A: Using Docker Compose (Recommended)**
```bash
# Set your API key
export OPENAI_API_KEY=sk-...
# OR
export GOOGLE_API_KEY=... # Gemini (free tier available)
# OR
export GROQ_API_KEY=... # Groq (free tier available)
# Start Hindsight
docker compose up -d
```
**Option B: Using Docker directly**
```bash
export OPENAI_API_KEY=sk-...
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API**: http://localhost:8888
- **Control Plane UI**: http://localhost:9999
### 2. Configure Environment
```bash
# Copy example config
cp .env.example .env
# Edit with your values
nano .env
```
Required settings:
```bash
# Hindsight
HINDSIGHT_API_URL=http://localhost:8888
HINDSIGHT_BANK_ID=blog-memory
# Sanity CMS
SANITY_PROJECT_ID=your-project-id
SANITY_DATASET=production
```
### 3. Install Dependencies
```bash
npm install
```
### 4. Sync Your Blog Posts
```bash
npm run sync
```
Expected output:
```
=======================================
Sanity -> Hindsight Blog Sync
=======================================
Setting up memory bank...
Memory bank "blog-memory" ready
Fetching posts from Sanity CMS...
Found 10 posts to sync
Syncing posts to Hindsight...
[1/10] "Why I Chose Qwik"... done
[2/10] "Building AI Agents"... done
...
=======================================
Sync Complete
=======================================
Synced: 10 posts
```
### 5. Query Your Content
```bash
npm run query
```
## Query Examples
### Semantic Search
Find related content using natural language:
```typescript
import { recallMemory } from './hindsight-client.js';
// Find posts about AI agents
const result = await recallMemory('AI agents and automation', {
budget: 'mid',
maxTokens: 2048,
});
console.log(`Found ${result.results.length} relevant posts`);
```
### Temporal Queries
Ask about content from specific time periods:
```typescript
// Posts from January 2025
const result = await recallMemory('What did I write about in January 2025?', {
queryTimestamp: '2025-01-31T23:59:59Z',
});
```
### Reflect for Insights
Generate AI-powered analysis of your content:
```typescript
import { reflectOnMemory } from './hindsight-client.js';
// Analyze blog themes
const insights = await reflectOnMemory(
'What are the main themes of my blog? What topics do I write about most?',
{ budget: 'high' }
);
console.log(insights.text);
```
### Related Content Discovery
Power your "Related Posts" feature:
```typescript
// Find posts similar to a specific article
const related = await recallMemory(
'Find posts related to "Why I Chose Qwik for My Personal Website"',
{ budget: 'mid' }
);
```
## Memory Structure
Each blog post is stored with rich metadata for optimal recall:
```
# Blog Post: {title}
**Published:** {date}
**URL:** {base_url}/blog/{slug}
**Tags:** {tags}
**Reading Time:** {reading_time}
## Description
{description}
## Content
{full_content}
```
Key features:
- **document_id**: `post:{slug}` - Enables upsert on re-sync
- **context**: `blog-post` - Categorizes the memory type
- **timestamp**: Post publication date - Enables temporal queries
## Use Cases
### 1. AI-Powered Blog Search
Replace keyword search with semantic understanding:
```typescript
// Old: keyword matching
const results = posts.filter(p => p.title.includes('React'));
// New: semantic understanding
const result = await recallMemory('frontend framework tutorials');
```
### 2. Content Recommendation Engine
Generate personalized recommendations:
```typescript
const recommendations = await reflectOnMemory(
'Based on a reader interested in "AI automation", recommend related posts'
);
```
### 3. Writing Assistant
Get topic suggestions based on your existing content:
```typescript
const suggestions = await reflectOnMemory(
'What topics should I write about next? What gaps exist in my content?'
);
```
### 4. Content Analytics
Analyze your blog's evolution:
```typescript
const analysis = await reflectOnMemory(
'How have my writing topics evolved over the past year?'
);
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_URL` | Hindsight API endpoint | `http://localhost:8888` |
| `HINDSIGHT_BANK_ID` | Memory bank identifier | `blog-memory` |
| `SANITY_PROJECT_ID` | Your Sanity project ID | (required) |
| `SANITY_DATASET` | Sanity dataset name | `production` |
| `SANITY_API_TOKEN` | Sanity API token (for private datasets) | (none) |
| `SANITY_API_VERSION` | Sanity API version | `2024-01-09` |
| `SITE_URL` | Your blog's base URL | `https://example.com` |
### Memory Bank Disposition
The memory bank is configured with disposition traits optimized for blog content:
```typescript
{
skepticism: 2, // Trusting - blog content is authoritative
literalism: 4, // Literal - exact content matters
empathy: 3, // Balanced
}
```
## Extending for Other CMS Platforms
This pattern can be adapted for any CMS. The key components:
### 1. CMS Client
Replace `sanity-client.ts` with your CMS:
```typescript
// contentful-client.ts
import { createClient } from 'contentful';
export async function getAllPosts(): Promise<BlogPost[]> {
const client = createClient({...});
const entries = await client.getEntries({ content_type: 'blogPost' });
return entries.items.map(transformPost);
}
```
### 2. Content Transformation
Ensure your content is formatted for semantic search:
```typescript
function formatPostContent(post: BlogPost): string {
return `# ${post.title}
**Published:** ${post.date}
...
${post.content}`;
}
```
### 3. Document ID Strategy
Use a consistent document ID for upsert behavior:
```typescript
await retainBlogPost(content, {
documentId: `post:${post.slug}`, // Unique, stable identifier
timestamp: post.date,
});
```
## Troubleshooting
### "Connection refused" error
Make sure Hindsight is running:
```bash
docker compose up -d
curl http://localhost:8888/health
```
### "No posts found" during sync
Check your Sanity configuration:
```bash
# Verify project ID
echo $SANITY_PROJECT_ID
# Test GROQ query
npx sanity query '*[_type == "post"][0..2]{title}'
```
### Slow recall/reflect responses
This is normal for the first query as Hindsight builds embeddings. Subsequent queries are faster. Use `budget: 'low'` for faster responses at the cost of recall quality.
## Resources
- [Hindsight Documentation](https://hindsight.vectorize.io/)
- [Hindsight GitHub](https://github.com/vectorize-io/hindsight)
- [Sanity CMS Documentation](https://www.sanity.io/docs)
- [Hindsight Cookbook](https://github.com/vectorize-io/hindsight-cookbook)
## License
MIT
@@ -0,0 +1,276 @@
---
sidebar_position: 7
---
# Stance Tracker
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/stancetracker)
:::
An AI-powered application that tracks political candidates' stances on issues over time using Hindsight memory system and web scraping.
## Features
- **Geographic Targeting**: Track stances by country, state/province, and city
- **Multi-Candidate Tracking**: Monitor multiple candidates simultaneously
- **Temporal Analysis**: Historical stance tracking with configurable time ranges
- **Automated Scraping**: Periodic content collection with configurable frequencies (hourly/daily/weekly)
- **Stance Change Detection**: Automatic detection and highlighting of position changes
- **Interactive Timeline**: Visual graph showing stance evolution with reference callouts
- **Source Attribution**: All stances linked to verified sources with excerpts
## Architecture
### Memory System (Hindsight Integration)
This app uses the Hindsight memory system from `github.com/vectorize-io/hindsight`:
1. **Banks**: Each scraper agent has its own memory bank
2. **Retain**: Stores candidate statements and web scraping results
3. **Recall**: Semantic search to retrieve relevant memories
4. **Reflect**: Generates contextual analysis using stored memories
5. **Temporal Search**: Queries memories within specific time periods
### Tech Stack
- **Frontend**: Next.js 16, React, TypeScript, TailwindCSS
- **Visualization**: Recharts for timeline graphs
- **Backend**: Next.js API routes
- **Memory**: Hindsight (from github.com/vectorize-io/hindsight)
- **Database**: JSON file storage (no database required)
- **Web Search**: Tavily API
- **LLM**: OpenAI/Anthropic/Groq (configurable)
- **Scheduling**: node-cron
## Prerequisites
1. **Hindsight API** running (from github.com/vectorize-io/hindsight)
2. **API Keys**:
- Tavily API key (for web search)
- LLM provider API key (OpenAI, Anthropic, or Groq)
## Setup
### 1. Install Dependencies
```bash
npm install
```
### 2. Configure Environment
Copy `.env.example` to `.env` and fill in your credentials:
```bash
cp .env.example .env
```
Edit `.env`:
```env
# Hindsight API (from github.com/vectorize-io/hindsight)
HINDSIGHT_API_URL=http://localhost:8888
# Tavily API (for web search)
TAVILY_API_KEY=your_tavily_api_key_here
# LLM Provider
LLM_PROVIDER=openai # or anthropic, groq
LLM_API_KEY=your_llm_api_key_here
LLM_MODEL=gpt-4-turbo-preview
```
### 3. Start Hindsight
Clone and run Hindsight from github.com/vectorize-io/hindsight:
```bash
# Clone and run github.com/vectorize-io/hindsight
cd /path/to/hindsight
cargo run --bin hindsight-server
```
Verify Hindsight is running at `http://localhost:8888`
### 4. Run the Application
```bash
npm run dev
```
Visit `http://localhost:3000`
## Usage
### Creating a Tracking Session
1. **Set Location**: Enter country (required), state/province, and city (optional)
2. **Choose Topic**: Specify the issue to track (e.g., "Climate Change Policy")
3. **Add Candidates**: Enter names of candidates/politicians to track
4. **Configure Time Range**: Set historical start/end dates for initial analysis
5. **Set Frequency**: Choose how often to check for updates (hourly/daily/weekly)
6. **Start Tracking**: Click "Start Tracking" to begin
### Viewing Results
- **Timeline Graph**: Shows confidence levels of each candidate's stance over time
- **Stance Changes**: Red circles on the graph indicate detected position changes
- **Click Points**: Click any point to see detailed stance information and sources
- **Source Links**: Each stance includes links to original references
### Managing Sessions
- **Pause/Resume**: Temporarily stop or restart tracking
- **Run Now**: Trigger an immediate update outside the schedule
- **Status**: View current session status and frequency
## API Endpoints
### Sessions
- `POST /api/sessions` - Create new tracking session
- `GET /api/sessions?id={id}` - Get session details
- `GET /api/sessions` - List all sessions
- `PATCH /api/sessions` - Update session status
### Stances
- `POST /api/stances` - Process candidate stance
- `GET /api/stances?sessionId={id}&candidate={name}` - Get stances
### Scheduler
- `POST /api/scheduler` - Control session scheduling
- Actions: `start`, `stop`, `run`
## Hindsight Integration Examples
### 1. Storing Memories
```typescript
// Store web scraping results
await hindsightClient.retain(bankId, articleContent, {
context: 'web_search_result',
timestamp: articleDate,
metadata: { url: articleUrl }
});
```
### 2. Semantic Search
```typescript
// Search for relevant memories
const results = await hindsightClient.recall(bankId, query, {
budget: 'high',
maxTokens: 8192
});
```
### 3. Temporal Filtering
```typescript
// Query memories up to a specific point in time
const results = await hindsightClient.recall(bankId, query, {
queryTimestamp: '2024-12-01T00:00:00Z'
});
```
### 4. Contextual Analysis
```typescript
// Generate analysis using stored memories
const response = await hindsightClient.reflect(bankId,
'What is the candidate\'s stance on this issue?',
{ budget: 'high' }
);
```
## Production Deployment
### Vercel Deployment
```bash
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Set environment variables in Vercel dashboard:
# - HINDSIGHT_API_URL
# - TAVILY_API_KEY
# - LLM_PROVIDER
# - LLM_API_KEY
# - LLM_MODEL
```
**Note**: The `data/` directory for JSON storage will be ephemeral on Vercel. For production, consider using a persistent database or object storage.
## Development
### Project Structure
```
stancetracker/
├── app/
│ ├── api/ # API routes
│ ├── globals.css # Global styles
│ ├── layout.tsx # Root layout
│ └── page.tsx # Main page
├── components/ # React components
├── lib/
│ ├── db/ # JSON database utilities
│ ├── hindsight-client.ts # Hindsight API client
│ ├── llm-client.ts # LLM provider client
│ ├── web-scraper.ts # Tavily web scraper
│ ├── scraper-agent.ts # Content scraper
│ ├── rag-system.ts # Memory retrieval
│ ├── stance-extractor.ts # Stance analysis
│ ├── stance-pipeline.ts # Main pipeline
│ └── scheduler.ts # Job scheduling
└── types/ # TypeScript types
```
### Adding New LLM Providers
Edit `lib/llm-client.ts` and add a new method:
```typescript
private async newProviderComplete(messages, options) {
// Implementation
}
```
## Limitations
- **Web Search**: Uses Tavily API which has rate limits
- **Source Verification**: Manual verification recommended for critical applications
- **Stance Extraction**: LLM-based, subject to model limitations
- **Storage**: JSON file storage is not suitable for high-scale production use
- **Rate Limits**: Respect API rate limits for Tavily, Hindsight, and LLM providers
## Future Enhancements
- [ ] Real-time social media monitoring
- [ ] Speech/video transcription analysis
- [ ] Multi-language support
- [ ] Sentiment analysis integration
- [ ] Comparative analysis dashboard
- [ ] Export to CSV/PDF
- [ ] Email notifications for stance changes
- [ ] Public API for third-party integrations
## License
MIT
## Support
For issues or questions, please check:
- Hindsight documentation: `github.com/vectorize-io/hindsight/README.md`
- Tavily API docs: https://tavily.com/
- Project issues: Create an issue in the repository
@@ -0,0 +1,122 @@
---
sidebar_position: 8
---
# Hindsight AI SDK - Personal Chef
:::info Complete Application
This is a complete, runnable application demonstrating Hindsight integration.
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/taste-ai)
:::
A personal food assistant demonstrating three key Hindsight integrations using the [Vercel AI SDK v6](https://sdk.vercel.ai/docs).
## Architecture: Single Bank with User Tags
This demo uses a **single Hindsight bank** (`taste-ai`) for all users, with each user's data tagged using `user:${username}`.
```typescript
// All users share the same bank
const BANK_ID = 'taste-ai';
// Each memory is tagged with the user
await hindsightTools.retain.execute({
bankId: BANK_ID,
content: userData,
tags: [`user:${username}`],
});
```
This architecture enables:
- **Per-user queries**: Filter by `user:alice` to get personalized results
- **Aggregated insights**: Query across all users to find popular recipes or common dietary patterns
- **Simplified management**: One bank to maintain instead of per-user banks
## Three Hindsight Integrations
### 1. Meal Suggestions with Memory Recall & Reflection
Uses `recall` and `reflect` tools with AI SDK's agent-based approach to gather personalized context.
```typescript
const contextResult = await generateText({
model: llmModel,
tools: {
recall: hindsightTools.recall,
reflect: hindsightTools.reflect,
},
toolChoice: 'auto',
prompt: `You are gathering context for personalized ${mealType} recipe suggestions.
Use the recall tool to search for the user's food preferences, dislikes, and recent meals.
Then use the reflect tool to analyze their dietary patterns and restrictions.
After gathering context, summarize their preferences and recent eating patterns.`,
});
```
The AI agent autonomously:
- Searches memory for cuisine preferences and dietary restrictions
- Analyzes recent protein consumption for variety
- Identifies foods to avoid
### 2. Goal Progress Tracking with Mental Models
Uses mental models to automatically maintain updated insights about user progress.
```typescript
// Create a mental model that auto-refreshes after new meals
await hindsightTools.createMentalModel.execute({
bankId: BANK_ID,
mentalModelId: getMentalModelId(username, 'goals'),
name: `${username}'s Goal Progress`,
sourceQuery: `Analyze ${username}'s dietary goals and eating patterns.
Describe their progress towards their stated goals (weight loss, muscle gain, etc.).`,
tags: [`user:${username}`],
autoRefresh: true, // Refreshes automatically after consolidation
});
// Query the mental model for current insights
const result = await hindsightTools.queryMentalModel.execute({
bankId: BANK_ID,
mentalModelId: mentalModelId,
});
```
Mental models automatically:
- Track progress towards dietary goals
- Update after each new meal is logged
- Provide fresh insights without manual refresh
### 3. Language Enforcement with Directives
Uses directives to ensure all responses match user's language preference.
```typescript
await hindsightClient.createDirective(BANK_ID, {
name: `${username}'s Language Preference`,
content: `Always respond in ${language}. All suggestions must be in ${language}.`,
priority: 100,
tags: [`user:${username}`, 'directive:language'],
});
```
Directives are automatically injected when mental models generate insights, ensuring consistent language across all interactions.
## Running the Demo
```bash
npm install
npm run dev
```
**Requirements:**
- Hindsight server running at `http://localhost:8888` (or set `HINDSIGHT_URL`)
- Node.js 18+
## Learn More
- [Hindsight AI SDK on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-ai-sdk)
- [AI SDK Documentation](https://sdk.vercel.ai/docs)
@@ -0,0 +1,138 @@
<div className="cookbook-page">
# Cookbook
Learn how to build with Hindsight through practical examples:
- **[Recipes](#recipes)** - Step-by-step guides and patterns for common use cases
- **[Applications](#applications)** - Complete, runnable applications demonstrating Hindsight integration
<RecipeCarousel
title="Recipes"
items={[
{
title: "Hindsight Quickstart",
href: "/cookbook/recipes/quickstart",
description: "Learn the basics: retain, recall, and reflect",
tags: { sdk: "hindsight-client", topic: "Quick Start" }
},
{
title: "Per-User Memory",
href: "/cookbook/recipes/per-user-memory",
description: "Build a chatbot with per-user memory isolation",
tags: { sdk: "hindsight-client", topic: "Learning" }
},
{
title: "Support Agent with Shared Knowledge",
href: "/cookbook/recipes/support-agent-shared-knowledge",
description: "Combine per-user memory with shared product documentation",
tags: { sdk: "hindsight-client", topic: "Learning" }
},
{
title: "Memory with LiteLLM",
href: "/cookbook/recipes/litellm-memory-demo",
description: "Add automatic memory to any LLM app using LiteLLM callbacks",
tags: { sdk: "hindsight-litellm", topic: "Quick Start" }
},
{
title: "Routing Tool Learning",
href: "/cookbook/recipes/tool-learning-demo",
description: "Teach an LLM which tool to use through feedback and memory",
tags: { sdk: "hindsight-litellm", topic: "Learning" }
},
{
title: "Fitness Coach with Hindsight Memory",
href: "/cookbook/recipes/fitness_tracker",
description: "Track workouts, diet, and progress with a personalized fitness coach",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Healthcare Assistant with Hindsight Memory",
href: "/cookbook/recipes/healthcare_assistant",
description: "A supportive chatbot that remembers patient history and preferences",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Movie Recommendation Assistant with Hindsight Memory",
href: "/cookbook/recipes/movie_recommendation",
description: "Get personalized movie recommendations that improve over time",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Personal AI Assistant with Hindsight Memory",
href: "/cookbook/recipes/personal_assistant",
description: "A general-purpose assistant that remembers your life and preferences",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Personalized Search Agent with Hindsight Memory",
href: "/cookbook/recipes/personalized_search",
description: "Search assistant that learns your location, diet, and lifestyle",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Study Buddy with Hindsight Memory",
href: "/cookbook/recipes/study_buddy",
description: "Track study sessions, identify knowledge gaps, and get personalized review suggestions",
tags: { sdk: "hindsight-client", topic: "Learning" }
}
]}
/>
<RecipeCarousel
title="Applications"
items={[
{
title: "Chat Memory App",
href: "/cookbook/applications/chat-memory",
description: "Real-time chat app with per-user memory using Groq and Hindsight",
tags: { sdk: "hindsight-client", topic: "Chat" }
},
{
title: "Deliveryman Demo",
href: "/cookbook/applications/deliveryman-demo",
description: "Delivery agent simulation demonstrating learning through mental models",
tags: { sdk: "hindsight-client", topic: "Learning" }
},
{
title: "Memory Approaches Comparison Demo",
href: "/cookbook/applications/hindsight-litellm-demo",
description: "Interactive comparison of memory approaches: none, full history, and semantic retrieval",
tags: { sdk: "hindsight-litellm", topic: "Quick Start" }
},
{
title: "Tool Learning Demo",
href: "/cookbook/applications/hindsight-tool-learning-demo",
description: "Show how Hindsight helps LLMs learn which tool to use when names are ambiguous",
tags: { sdk: "hindsight-litellm", topic: "Learning" }
},
{
title: "OpenAI Agent + Hindsight Memory Integration",
href: "/cookbook/applications/openai-fitness-coach",
description: "Fitness coach using OpenAI Assistants with Hindsight as memory backend",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Sanity CMS Blog Memory",
href: "/cookbook/applications/sanity-blog-memory",
description: "Sync Sanity CMS blog posts to Hindsight for semantic search and AI insights",
tags: { sdk: "hindsight-client", topic: "Learning" }
},
{
title: "Stance Tracker",
href: "/cookbook/applications/stancetracker",
description: "Track political candidates' stances over time with automated web scraping",
tags: { sdk: "hindsight-client", topic: "Recommendation" }
},
{
title: "Hindsight AI SDK - Personal Chef",
href: "/cookbook/applications/taste-ai",
description: "Personal food assistant with AI SDK v6 showcasing recall, mental models, and directives",
tags: { sdk: "@vectorize-io/hindsight-ai-sdk", topic: "Recommendation" }
}
]}
/>
</div>
@@ -0,0 +1,306 @@
---
sidebar_position: 6
---
# Fitness Coach with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/fitness_tracker.ipynb)
:::
A personalized fitness assistant that tracks your workouts, diet, recovery, and progress over time to give contextual advice.
## Features
- Logs workout sessions with exercises and weights
- Tracks meals and dietary preferences
- Monitors recovery and sleep patterns
- Provides personalized training advice
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
!pip install -q hindsight-client openai nest-asyncio
```
## 2. Configure OpenAI API Key
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
print("API key configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from datetime import datetime
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
USER_ID = "fitness-user-demo"
print("Clients initialized!")
```
## 4. Define Helper Functions
```python
def log_workout(workout_details: str) -> str:
"""Log a workout session with timestamp."""
today = datetime.now().strftime("%B %d, %Y")
hindsight.retain(
bank_id=USER_ID,
content=f"{today} - WORKOUT LOG: {workout_details}",
metadata={"category": "workout", "date": today},
)
return f"Logged workout for {today}: {workout_details}"
def log_meal(meal_details: str) -> str:
"""Log a meal with timestamp."""
today = datetime.now().strftime("%B %d, %Y")
hindsight.retain(
bank_id=USER_ID,
content=f"{today} - MEAL LOG: {meal_details}",
metadata={"category": "nutrition", "date": today},
)
return f"Logged meal for {today}: {meal_details}"
def log_recovery(recovery_details: str) -> str:
"""Log recovery information (sleep, soreness, etc.)."""
today = datetime.now().strftime("%B %d, %Y")
hindsight.retain(
bank_id=USER_ID,
content=f"{today} - RECOVERY LOG: {recovery_details}",
metadata={"category": "recovery", "date": today},
)
return f"Logged recovery for {today}: {recovery_details}"
def store_user_profile(profile_info: str) -> str:
"""Store user profile information."""
hindsight.retain(
bank_id=USER_ID,
content=f"USER PROFILE: {profile_info}",
metadata={"category": "profile"},
)
return f"Stored profile info: {profile_info}"
def fitness_coach(user_query: str) -> str:
"""Get personalized fitness advice based on query and user history."""
memories = hindsight.recall(
bank_id=USER_ID,
query=f"fitness workout diet recovery goals {user_query}",
budget="high",
)
memory_context = ""
if memories and memories.results:
memory_context = "\n".join(f"- {m.text}" for m in memories.results[:10])
system_prompt = f"""You are a knowledgeable and supportive fitness coach.
You have access to the user's workout history, diet logs, recovery notes, and personal profile.
What you know about this user:
{memory_context if memory_context else "No history recorded yet."}
Provide personalized, actionable advice based on their:
- Training history and progress
- Dietary preferences and restrictions
- Recovery patterns
- Personal goals
Be encouraging but realistic. Reference their specific history when relevant."""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query},
],
temperature=0.7,
max_tokens=600,
)
advice = response.choices[0].message.content
hindsight.retain(
bank_id=USER_ID,
content=f"User asked: {user_query}\nCoach advised: {advice[:200]}...",
metadata={"category": "coaching"},
)
return advice
def get_progress_report() -> str:
"""Generate a progress report based on workout history."""
report = hindsight.reflect(
bank_id=USER_ID,
query="""Analyze this user's fitness journey:
1. How consistent have they been with workouts?
2. What progress have they made (weight lifted, exercises)?
3. How is their recovery and sleep?
4. What dietary patterns do you notice?
5. What should they focus on next?""",
budget="high",
)
return report.text if hasattr(report, 'text') else str(report)
print("Helper functions defined!")
```
## 5. Set Up User Profile
```python
print("Setting up user profile...")
profile_data = [
"Name: Anish, Age: 26, Height: 5'10\", Weight: 72kg",
"Goal: Building lean muscle, started gym 6 months ago",
"Routine: Push-pull-legs split, 5x per week",
"Rest days: Wednesday and Sunday",
"Dietary restriction: Mild lactose intolerance, uses almond milk",
"Health note: Occasional knee pain, avoids deep squats",
"Supplements: Whey protein (lactose-free), magnesium",
"Sleep: Aims for 7+ hours, performance drops under 6 hours",
]
for info in profile_data:
store_user_profile(info)
print(f" Stored: {info[:50]}...")
```
## 6. Log Workout History
```python
print("Logging workout history...")
workouts = [
"Push day: Bench press 3x8 @ 60kg, overhead press 4x12, tricep dips 3x10. Felt strong.",
"Pull day: Deadlift 3x5 @ 80kg, barbell rows 4x10, bicep curls 3x12. Good session.",
"Leg day: Leg press 4x12, hamstring curls 3x12, glute bridges 3x15. Knee felt okay.",
]
for workout in workouts:
print(f" {log_workout(workout)[:60]}...")
print("\nLogging recent meals...")
meals = [
"Post-workout: Whey shake with almond milk, banana, oats",
"Dinner: Grilled chicken, brown rice, steamed vegetables",
"Snack: Greek yogurt (lactose-free) with berries",
]
for meal in meals:
print(f" {log_meal(meal)[:60]}...")
print("\nLogging recovery notes...")
recovery = [
"Slept 7.5 hours, feeling well rested",
"Some DOMS in legs from yesterday, using turmeric milk",
]
for note in recovery:
print(f" {log_recovery(note)[:60]}...")
```
## 7. Talk to Your Fitness Coach
```python
import time
print("=" * 60)
print(" Talking to your fitness coach...")
print("=" * 60)
queries = [
"How much was I lifting for bench press recently?",
"I slept poorly last night (only 5 hours). What should I do for today's workout?",
"Suggest a post-workout meal that works with my dietary restrictions.",
"My knee has been bothering me more. Any exercise modifications?",
]
for query in queries:
print(f"\nUser: {query}")
print("-" * 40)
response = fitness_coach(query)
print(f"Coach: {response}")
time.sleep(1)
```
## 8. Generate Progress Report
```python
print("=" * 60)
print(" Progress Report")
print("=" * 60)
print(get_progress_report())
```
## 9. Try Your Own Query
```python
your_query = "What exercises should I do today?" # Change this!
print(f"You: {your_query}")
print("-" * 40)
print(f"Coach: {fitness_coach(your_query)}")
```
## 10. Cleanup
```python
hindsight.close()
print("Client connection closed.")
```
@@ -0,0 +1,299 @@
---
sidebar_position: 7
---
# Healthcare Assistant with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/healthcare_assistant.ipynb)
:::
A supportive healthcare chatbot that remembers patient history, symptoms, medications, and preferences to provide personalized guidance.
## Disclaimer
**This is a demo application and should NOT be used for actual medical advice. Always consult qualified healthcare professionals.**
## Features
- Tracks symptoms, medications, and allergies
- Maintains patient history across conversations
- Provides health information and wellness tips
- Schedules appointments
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
!pip install -q hindsight-client openai nest-asyncio
```
## 2. Configure OpenAI API Key
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
print("API key configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from datetime import datetime
import random
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
PATIENT_ID = "patient-demo"
def get_patient_bank_id(patient_id: str) -> str:
return f"patient-{patient_id}"
print("Clients initialized!")
```
## 4. Define Helper Functions
```python
def store_patient_info(patient_id: str, info: str, category: str = "general") -> str:
"""Store patient information."""
bank_id = get_patient_bank_id(patient_id)
today = datetime.now().strftime("%B %d, %Y")
hindsight.retain(
bank_id=bank_id,
content=f"{today} - {category.upper()}: {info}",
metadata={"category": category, "date": today},
)
return f"Recorded {category}: {info}"
def get_patient_history(patient_id: str, query: str) -> str:
"""Retrieve relevant patient history."""
bank_id = get_patient_bank_id(patient_id)
memories = hindsight.recall(
bank_id=bank_id,
query=query,
budget="high",
)
if memories and memories.results:
return "\n".join(f"- {m.text}" for m in memories.results[:10])
return "No relevant history found."
def healthcare_chat(patient_id: str, user_message: str) -> str:
"""Chat with the healthcare assistant."""
bank_id = get_patient_bank_id(patient_id)
history = get_patient_history(
patient_id,
f"symptoms medications allergies conditions {user_message}"
)
system_prompt = f"""You are a supportive healthcare assistant chatbot.
IMPORTANT DISCLAIMERS:
- You are NOT a doctor and cannot provide medical diagnoses
- Always recommend consulting healthcare professionals for serious concerns
- Never prescribe medications or suggest stopping prescribed treatments
Your role:
- Listen empathetically to patient concerns
- Remember and reference their medical history
- Provide general health information and wellness tips
- Help track symptoms over time
- Remind about medications and appointments
- Suggest when to seek professional care
Patient History:
{history}
Guidelines:
- Be warm and supportive
- Ask clarifying questions when needed
- Reference their history when relevant
- Flag any concerning symptoms for professional review"""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0.7,
max_tokens=600,
)
answer = response.choices[0].message.content
hindsight.retain(
bank_id=bank_id,
content=f"Patient concern: {user_message}\nGuidance provided: {answer[:200]}...",
metadata={"category": "consultation"},
)
return answer
def get_health_summary(patient_id: str) -> str:
"""Generate a health summary for the patient."""
bank_id = get_patient_bank_id(patient_id)
summary = hindsight.reflect(
bank_id=bank_id,
query="""Summarize this patient's health profile:
1. Known conditions and diagnoses
2. Current medications
3. Allergies and sensitivities
4. Recent symptoms reported
5. Lifestyle factors mentioned
6. Any patterns or trends in their health""",
budget="high",
)
return summary.text if hasattr(summary, 'text') else str(summary)
def schedule_appointment(patient_id: str, appointment_type: str, preferred_time: str) -> str:
"""Schedule an appointment (demo)."""
confirmation_id = f"APT-{random.randint(10000, 99999)}"
store_patient_info(
patient_id,
f"Appointment scheduled: {appointment_type} - Preferred time: {preferred_time} - Confirmation: {confirmation_id}",
category="appointment"
)
return f"Appointment requested: {appointment_type}\nPreferred time: {preferred_time}\nConfirmation ID: {confirmation_id}\n\nA staff member will confirm the exact time within 24 hours."
print("Helper functions defined!")
```
## 5. Set Up Patient Profile
```python
print("Setting up patient profile...")
patient_info = [
("Age: 45, Male, Height: 5'11\", Weight: 185 lbs", "demographics"),
("Allergy: Penicillin - causes hives", "allergies"),
("Allergy: Shellfish - causes throat swelling", "allergies"),
("Current medication: Lisinopril 10mg daily for blood pressure", "medications"),
("Current medication: Metformin 500mg twice daily for Type 2 diabetes", "medications"),
("Condition: Diagnosed with Type 2 diabetes in 2020", "conditions"),
("Condition: Mild hypertension, well-controlled", "conditions"),
("Family history: Father had heart disease", "family_history"),
("Lifestyle: Sedentary job, trying to exercise more", "lifestyle"),
]
for info, category in patient_info:
result = store_patient_info(PATIENT_ID, info, category)
print(f" {result}")
```
## 6. Healthcare Chat
```python
import time
print("=" * 60)
print(" Healthcare Chat")
print("=" * 60)
conversations = [
"Hi, I've been having headaches for the past few days. Should I be worried?",
"The headaches are mostly in the afternoon. I've also been feeling more tired than usual.",
"I've been checking my blood sugar and it's been a bit higher lately, around 140-150 fasting.",
"Can you remind me what allergies I have? I'm going to a new restaurant.",
]
for message in conversations:
print(f"\nPatient: {message}")
print("-" * 40)
response = healthcare_chat(PATIENT_ID, message)
print(f"Assistant: {response}")
time.sleep(1)
```
## 7. Schedule Appointment
```python
print("=" * 60)
print(" Scheduling Appointment")
print("=" * 60)
print(schedule_appointment(PATIENT_ID, "General checkup", "Next Tuesday afternoon"))
```
## 8. Health Summary
```python
print("=" * 60)
print(" Patient Health Summary")
print("=" * 60)
print(get_health_summary(PATIENT_ID))
```
## 9. Try Your Own Question
```python
your_question = "Should I adjust my Metformin dose?" # Change this!
print(f"You: {your_question}")
print("-" * 40)
print(f"Assistant: {healthcare_chat(PATIENT_ID, your_question)}")
```
## 10. Cleanup
```python
hindsight.close()
print("Client connection closed.")
```
@@ -0,0 +1,187 @@
---
sidebar_position: 4
---
# Memory with LiteLLM
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/04-litellm-memory-demo.ipynb)
:::
This notebook demonstrates how to add persistent memory to any LLM app using the `hindsight-litellm` package. Memory storage and injection happen automatically via LiteLLM callbacks - no manual memory management needed!
**Key features demonstrated:**
1. `configure()` + `enable()` - Set up automatic memory integration
2. Automatic storage - Conversations are stored after each LLM call
3. Automatic injection - Relevant memories are injected into prompts
The `hindsight-litellm` package hooks into LiteLLM's callback system to:
- Store each conversation after successful LLM responses
- Inject relevant memories into the system prompt before LLM calls
## Prerequisites
Make sure you have Hindsight running:
```bash
export OPENAI_API_KEY=your-key
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- API: http://localhost:8888
- UI: http://localhost:9999
## Installation
```python
!pip install hindsight-litellm litellm nest_asyncio python-dotenv -U -q
```
## Setup
```python
import os
import uuid
import time
import logging
import nest_asyncio
from dotenv import load_dotenv
# Apply nest_asyncio for Jupyter compatibility
nest_asyncio.apply()
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
logging.getLogger("LiteLLM Proxy").setLevel(logging.WARNING)
# Import hindsight_litellm
import hindsight_litellm
# Configuration
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
# Check for API key
if not os.getenv("OPENAI_API_KEY"):
print("Warning: OPENAI_API_KEY not set")
```
## Configure and Enable Automatic Memory
This is all you need! After this, all LiteLLM calls will automatically:
- Have relevant memories injected into the prompt
- Store conversations to Hindsight after the response
```python
# Generate a unique bank_id for this demo session
bank_id = f"demo-{uuid.uuid4().hex[:8]}"
print(f"Using bank_id: {bank_id}")
# Configure and enable hindsight
hindsight_litellm.configure(
hindsight_api_url=HINDSIGHT_API_URL,
bank_id=bank_id,
store_conversations=True, # Automatically store conversations
inject_memories=True, # Automatically inject relevant memories
verbose=True, # Enable logging to debug memory operations
)
hindsight_litellm.enable()
print("Hindsight memory integration enabled!")
```
## Conversation 1: User Introduces Themselves
In this first conversation, the user shares some information about themselves. This will be automatically stored to Hindsight memory.
```python
user_message_1 = "Hi! I'm Alex and I work at Google as a software engineer. I love Python and machine learning."
print(f"User: {user_message_1}\n")
# Use hindsight_litellm.completion() directly
response_1 = hindsight_litellm.completion(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": user_message_1}
],
)
assistant_response_1 = response_1.choices[0].message.content
print(f"Assistant: {assistant_response_1}")
print("\n(Conversation automatically stored to Hindsight)")
```
## Wait for Memory Processing
Hindsight needs a few seconds to process and extract facts from the conversation.
```python
print("Waiting 12 seconds for memory processing...")
time.sleep(12)
print("Done!")
```
## Conversation 2: Test Memory-Augmented Response
Now we start a fresh conversation and ask what the assistant remembers. The memories from the previous conversation will be automatically injected into the prompt!
```python
user_message_2 = "What do you know about me? What programming language should I use for my next project?"
print(f"User: {user_message_2}\n")
# Memories are automatically injected before this call!
response_2 = hindsight_litellm.completion(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": user_message_2}
],
)
print(f"Assistant: {response_2.choices[0].message.content}")
```
## Summary
The assistant should have remembered that Alex:
- Works at Google as a software engineer
- Loves Python and machine learning
And it should have recommended Python based on that knowledge!
```python
print(f"Memories stored in bank: {bank_id}")
print(f"View in UI: http://localhost:9999/banks/{bank_id}")
```
## Cleanup
```python
hindsight_litellm.cleanup()
# Optional: delete the bank
import requests
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
print(f"Deleted bank: {response.json()}")
```
@@ -0,0 +1,246 @@
---
sidebar_position: 8
---
# Movie Recommendation Assistant with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/movie_recommendation.ipynb)
:::
A personalized movie recommender that remembers your preferences, watch history, and tastes to give better suggestions over time.
## Features
- Remembers favorite genres, directors, and actors
- Tracks movies you've watched and enjoyed
- Provides contextual recommendations based on mood
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
!pip install -q hindsight-client openai nest-asyncio
```
## 2. Configure OpenAI API Key
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
print("API key configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
# Initialize OpenAI client
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# Unique identifier for this user's memory bank
USER_ID = "movie-fan-demo"
print("Clients initialized!")
```
## 4. Define Helper Functions
These functions demonstrate the three core Hindsight operations:
- **retain()**: Store memories
- **recall()**: Retrieve relevant memories
- **reflect()**: Synthesize insights from memories
```python
def get_recommendation(user_query: str) -> str:
"""
Get a movie recommendation based on user query and remembered preferences.
"""
# Recall relevant memories about this user's movie preferences
memories = hindsight.recall(
bank_id=USER_ID,
query=f"movie preferences tastes genres {user_query}",
budget="mid",
)
# Build context from memories
memory_context = ""
if memories and memories.results:
memory_context = "\n".join(
f"- {m.text}" for m in memories.results[:5]
)
# Generate recommendation with context
system_prompt = f"""You are a helpful movie recommendation assistant.
You remember the user's preferences and past conversations to give personalized suggestions.
What you know about this user:
{memory_context if memory_context else "No previous preferences recorded yet."}
Give thoughtful, personalized recommendations based on their tastes.
If they mention new preferences, acknowledge them."""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query},
],
temperature=0.7,
max_tokens=500,
)
recommendation = response.choices[0].message.content
# Store this interaction for future context
hindsight.retain(
bank_id=USER_ID,
content=f"User asked: {user_query}\nRecommendation given: {recommendation}",
metadata={"category": "movie_recommendation"},
)
return recommendation
def store_preference(preference: str) -> None:
"""Store an explicit user preference."""
hindsight.retain(
bank_id=USER_ID,
content=f"User preference: {preference}",
metadata={"category": "preference"},
)
print(f"Stored preference: {preference}")
def get_preference_summary() -> str:
"""Get a summary of what we know about the user's movie tastes."""
summary = hindsight.reflect(
bank_id=USER_ID,
query="Summarize this user's movie preferences, favorite genres, actors they like, and movies they've mentioned enjoying or disliking.",
budget="high",
)
return summary.text if hasattr(summary, 'text') else str(summary)
print("Helper functions defined!")
```
## 5. Run the Demo
Watch how the assistant learns and remembers preferences across conversations.
```python
import time
print("=" * 60)
print(" Movie Recommendation Assistant with Memory")
print("=" * 60)
print()
# Simulate a conversation over time
conversations = [
"I'm looking for a movie to watch tonight. Any suggestions?",
"I really loved Inception and Interstellar. Christopher Nolan is amazing!",
"Can you suggest something similar to those? I like mind-bending plots.",
"Actually, I'm not in the mood for something heavy. Something lighter?",
"I watched The Grand Budapest Hotel last week and loved it!",
"What should I watch tonight? Remember what I like!",
]
for i, query in enumerate(conversations, 1):
print(f"\n[Conversation {i}]")
print(f"User: {query}")
print("-" * 40)
response = get_recommendation(query)
print(f"Assistant: {response}")
print()
time.sleep(1)
```
## 6. View Learned Preferences
Use `reflect()` to synthesize what Hindsight has learned about your movie tastes.
```python
print("=" * 60)
print(" What I've learned about your movie tastes:")
print("=" * 60)
print(get_preference_summary())
```
## 7. Try Your Own Queries
Experiment with your own movie preferences!
```python
# Try your own query!
your_query = "I'm in the mood for a sci-fi thriller" # Change this!
print(f"You: {your_query}")
print("-" * 40)
print(f"Assistant: {get_recommendation(your_query)}")
```
## 8. Cleanup
Close the Hindsight client connection.
```python
hindsight.close()
print("Client connection closed.")
```
```python
```
```python
```
@@ -0,0 +1,247 @@
---
sidebar_position: 2
---
# Per-User Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/02-per-user-memory.ipynb)
:::
The simplest pattern: give your agent persistent memory for each user. The agent remembers past conversations, user preferences, and context across sessions.
## The Problem
Without memory, every conversation starts from scratch:
```
Session 1: "I prefer dark mode and use Python"
Session 2: "What's my preferred language?" → Agent doesn't know
```
## The Solution: One Bank Per User
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User A Bank │ │ User B Bank │ │ User C Bank │
│ │ │ │ │ │
│ - Conversations│ │ - Conversations│ │ - Conversations│
│ - Preferences │ │ - Preferences │ │ - Preferences │
│ - Context │ │ - Context │ │ - Context │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
100% isolated 100% isolated 100% isolated
```
Each user gets their own memory bank. Complete isolation, simple mental model.
```python
!pip install hindsight-client nest_asyncio openai python-dotenv -U
```
## 1. Create a Bank When User Signs Up
```python
# Jupyter notebooks already run an asyncio event loop. The hindsight client
# uses loop.run_until_complete() internally, but Python doesn't allow nested
# event loops by default. nest_asyncio patches this to allow nesting.
import nest_asyncio
nest_asyncio.apply()
import os
from dotenv import load_dotenv
from openai import OpenAI as OpenAIClient
# Load environment variables from .env file
# Copy .env.example to .env and fill in your values
load_dotenv()
# Configuration (override with env vars if set)
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
from hindsight_client import Hindsight
client = Hindsight(base_url=HINDSIGHT_API_URL)
llm = OpenAIClient() # Uses OPENAI_API_KEY from .env
def on_user_signup(user_id: str):
client.create_bank(
bank_id=f"user-{user_id}",
name=f"Memory for {user_id}"
)
print(f"View bank: {HINDSIGHT_UI_URL}/banks/user-{user_id}?view=documents")
```
## 2. Manage Conversation Sessions
Use `document_id` to group messages belonging to the same conversation. When you retain with the same `document_id`, Hindsight replaces the previous version (upsert behavior), keeping the memory up-to-date as the conversation evolves.
```python
import uuid
import json
class ConversationSession:
def __init__(self, user_id: str):
self.user_id = user_id
self.session_id = str(uuid.uuid4()) # Unique ID for this conversation
self.messages = []
def add_message(self, role: str, content: str):
self.messages.append({"role": role, "content": content})
def save(self, client: Hindsight):
"""Save the entire conversation. Replaces previous version if session_id exists."""
# Convert messages to string format for retain
content = "\n".join([f"{m['role']}: {m['content']}" for m in self.messages])
client.retain(
bank_id=f"user-{self.user_id}",
content=content,
document_id=self.session_id # Same ID = upsert (replace old version)
)
```
## 3. Recall Context Before Responding
```python
def get_context(user_id: str, query: str):
result = client.recall(
bank_id=f"user-{user_id}",
query=query
)
return result.results
```
## 4. Complete Agent Loop
```python
def format_results(results):
"""Format recall results for the prompt."""
if not results:
return "No relevant memories found."
return "\n".join([f"- {r.text}" for r in results])
def format_messages(messages):
"""Format conversation messages for the prompt."""
return "\n".join([f"{m['role']}: {m['content']}" for m in messages])
def handle_message(session: ConversationSession, user_message: str):
# 1. Add user message to session
session.add_message("user", user_message)
# 2. Recall relevant context from past conversations
context = client.recall(
bank_id=f"user-{session.user_id}",
query=user_message
)
# 3. Build system prompt with memory
system_prompt = f"""You are a helpful assistant with memory of past conversations.
## What you remember about this user
{format_results(context.results)}
Respond helpfully and reference relevant memories when appropriate."""
# 4. Generate response using OpenAI
response = llm.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
*[{"role": m["role"], "content": m["content"]} for m in session.messages]
]
)
assistant_response = response.choices[0].message.content
# 5. Add assistant response to session
session.add_message("assistant", assistant_response)
# 6. Save the updated conversation (upserts based on session_id)
session.save(client)
print(f"User: {user_message}")
print(f"Assistant: {assistant_response}\n")
return assistant_response
```
## 5. Starting a New Conversation
```python
# Create the user's bank
on_user_signup("alice")
# Each new conversation gets a new session with a unique ID
session = ConversationSession(user_id="alice")
# Multiple exchanges in the same conversation
handle_message(session, "Hi! I'm working on a Python project")
handle_message(session, "Can you help me with async/await?")
# View the stored conversation in the UI.
# Each message updates the same document (via document_id), so you'll see
# the full conversation history in a single document rather than separate entries.
print(f"\nView documents: {HINDSIGHT_UI_URL}/banks/user-alice?view=documents")
```
## How Document ID Works
The `document_id` parameter is key to managing evolving conversations:
| Scenario | Behavior |
|----------|----------|
| First retain with `document_id="session_123"` | Creates new document |
| Retain again with same `document_id="session_123"` | **Replaces** previous version (upsert) |
| Retain with different `document_id="session_456"` | Creates separate document |
| Retain without `document_id` | Creates new document each time |
This upsert behavior means:
- You always retain the **full conversation** state
- Facts are re-extracted from the complete conversation
- No duplicate or stale facts from old versions
- Memory stays consistent as conversations evolve
## What Gets Remembered
Hindsight automatically extracts and connects:
- **Facts**: "User prefers Python", "User is building a CLI tool"
- **Entities**: People, projects, technologies mentioned
- **Relationships**: How entities relate to each other
- **Temporal context**: When things happened
You don't need to manually extract or structure this - just retain the conversations.
## When to Use This Pattern
**Good fit:**
- Chatbots and assistants
- Personal AI companions
- Any 1:1 user-to-agent interaction
**Consider adding shared knowledge if:**
- You have product docs or FAQs to reference
- Multiple users need access to the same information
- See the Support Agent with Shared Knowledge notebook
## Cleanup
Delete the banks created during this notebook:
```python
import requests
# Delete the user-alice bank
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/user-alice")
print(f"Deleted user-alice: {response.json()}")
```
@@ -0,0 +1,266 @@
---
sidebar_position: 9
---
# Personal AI Assistant with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/personal_assistant.ipynb)
:::
A general-purpose personal assistant that remembers your preferences, schedule, family, work context, and past conversations.
## Features
- Remembers family, work, and personal details
- Tracks preferences and habits
- Helps with scheduling and reminders
- Maintains context across conversations
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
!pip install -q hindsight-client openai nest-asyncio
```
## 2. Configure OpenAI API Key
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
print("API key configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from datetime import datetime
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
USER_ID = "assistant-user-demo"
print("Clients initialized!")
```
## 4. Define Helper Functions
```python
def remember(info: str, category: str = "general") -> str:
"""Store information to remember."""
today = datetime.now().strftime("%B %d, %Y")
hindsight.retain(
bank_id=USER_ID,
content=f"{today}: {info}",
metadata={"category": category, "date": today},
)
return f"I'll remember: {info}"
def recall_context(query: str) -> str:
"""Recall relevant memories for context."""
memories = hindsight.recall(
bank_id=USER_ID,
query=query,
budget="high",
)
if memories and memories.results:
return "\n".join(f"- {m.text}" for m in memories.results[:8])
return ""
def chat(user_message: str) -> str:
"""Chat with the personal assistant."""
context = recall_context(user_message)
system_prompt = f"""You are a helpful personal AI assistant with long-term memory.
You remember the user's preferences, schedule, family, work context, and past conversations.
What you remember about this user:
{context if context else "No memories recorded yet."}
Your capabilities:
- Remember things when asked ("Remember that...", "Don't forget...")
- Recall past information ("What did I tell you about...", "When is...")
- Provide personalized suggestions based on known preferences
- Help with scheduling and reminders
- Have natural conversations while maintaining context
Be helpful, proactive, and reference relevant memories naturally."""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0.7,
max_tokens=500,
)
answer = response.choices[0].message.content
# Check if user is asking to remember something
lower_msg = user_message.lower()
if any(phrase in lower_msg for phrase in ["remember that", "don't forget", "remind me", "note that"]):
hindsight.retain(
bank_id=USER_ID,
content=f"User asked to remember: {user_message}",
metadata={"category": "reminder"},
)
# Store the interaction
hindsight.retain(
bank_id=USER_ID,
content=f"Conversation - User: {user_message[:100]} | Assistant: {answer[:100]}",
metadata={"category": "conversation"},
)
return answer
def get_summary(topic: str = None) -> str:
"""Get a summary of memories."""
query = f"Summarize what you know about {topic}" if topic else \
"Summarize everything you know about this user"
summary = hindsight.reflect(
bank_id=USER_ID,
query=query,
budget="high",
)
return summary.text if hasattr(summary, 'text') else str(summary)
print("Helper functions defined!")
```
## 5. Build Context
```python
print("Building context...")
initial_context = [
("My name is Alex and I work as a product manager at TechCorp", "personal"),
("My wife's name is Sarah and we have two kids: Emma (7) and Jack (4)", "family"),
("I prefer morning meetings and try to keep afternoons for deep work", "preference"),
("My mom's birthday is March 15th", "event"),
("I'm trying to read more - currently reading 'Atomic Habits'", "hobby"),
("I have a weekly team standup every Monday at 10am", "schedule"),
("I'm allergic to cats", "health"),
("My favorite coffee is a flat white with oat milk", "preference"),
("I'm training for a half marathon in April", "goal"),
]
for info, category in initial_context:
result = remember(info, category)
print(f" {result}")
```
## 6. Have a Conversation
```python
import time
print("=" * 60)
print(" Conversation")
print("=" * 60)
conversations = [
"Hey, what's my wife's name again?",
"Remember that my Q1 review is next Thursday at 2pm",
"I need a gift idea for my mom's birthday",
"What time is my Monday standup?",
"Can you recommend a coffee order for me?",
"What books am I reading?",
]
for message in conversations:
print(f"\nAlex: {message}")
print("-" * 40)
response = chat(message)
print(f"Assistant: {response}")
time.sleep(1)
```
## 7. View Summary
```python
print("=" * 60)
print(" What I Know About You")
print("=" * 60)
print(get_summary())
```
```python
print("=" * 60)
print(" Your Family")
print("=" * 60)
print(get_summary("family"))
```
## 8. Try Your Own Message
```python
your_message = "What should I focus on this month with my training?" # Change this!
print(f"You: {your_message}")
print("-" * 40)
print(f"Assistant: {chat(your_message)}")
```
## 9. Cleanup
```python
hindsight.close()
print("Client connection closed.")
```
@@ -0,0 +1,299 @@
---
sidebar_position: 10
---
# Personalized Search Agent with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/personalized_search.ipynb)
:::
A search assistant that learns your preferences, location, dietary needs, and lifestyle to provide contextually relevant search results.
## Features
- Learns location, dietary restrictions, and lifestyle
- Personalizes search queries based on context
- Remembers past searches and preferences
- Integrates with Tavily for real web search (optional)
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
- Tavily API key (optional, for real web search)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
# Tavily is optional - demo works with simulated results if not installed
!pip install -q hindsight-client openai tavily-python nest-asyncio
```
## 2. Configure API Keys
Enter your API keys when prompted. Tavily is optional - press Enter to skip for simulated search results.
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
# Tavily is optional - for real web search
if not os.getenv("TAVILY_API_KEY"):
tavily_key = getpass.getpass("Enter your Tavily API key (or press Enter to skip): ")
if tavily_key:
os.environ["TAVILY_API_KEY"] = tavily_key
print("API keys configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# Optional: Tavily for real web search
try:
from tavily import TavilyClient
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
HAS_TAVILY = True
print("Tavily configured - using real web search!")
except (ImportError, Exception) as e:
HAS_TAVILY = False
print("Note: Using simulated search results (Tavily not configured)")
USER_ID = "search-user-demo"
print("Clients initialized!")
```
## 4. Define Helper Functions
```python
def store_preference(preference: str) -> str:
"""Store a user preference."""
hindsight.retain(
bank_id=USER_ID,
content=f"User preference: {preference}",
metadata={"category": "preference"},
)
return f"Learned: {preference}"
def store_interaction(query: str, response: str) -> None:
"""Store a search interaction."""
hindsight.retain(
bank_id=USER_ID,
content=f"Search query: {query}\nResult highlights: {response[:200]}",
metadata={"category": "search_history"},
)
def get_user_context(query: str) -> str:
"""Retrieve relevant user context."""
memories = hindsight.recall(
bank_id=USER_ID,
query=f"preferences location dietary lifestyle {query}",
budget="mid",
)
if memories and memories.results:
return "\n".join(f"- {m.text}" for m in memories.results[:6])
return ""
def personalized_search(query: str) -> str:
"""Perform a personalized search."""
user_context = get_user_context(query)
enhancement_prompt = f"""Given this user's preferences and the search query, suggest how to enhance the search.
User preferences:
{user_context if user_context else "No preferences recorded yet."}
Search query: {query}
Return a JSON object with:
- "enhanced_query": The improved search query incorporating relevant preferences
- "filters": Any specific filters to apply (e.g., "vegetarian", "within 5 miles")
- "reasoning": Brief explanation of personalizations applied"""
enhancement = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": enhancement_prompt}],
temperature=0.3,
max_tokens=300,
)
enhanced_info = enhancement.choices[0].message.content
# Perform the search
if HAS_TAVILY:
search_results = tavily.search(
query=query,
search_depth="advanced",
max_results=5,
)
results_text = "\n".join(
f"- {r['title']}: {r['content'][:150]}..."
for r in search_results.get('results', [])
)
else:
results_text = f"[Simulated search results for: {query}]"
response_prompt = f"""Based on the search results and user preferences, provide a personalized summary.
User preferences:
{user_context if user_context else "No preferences recorded yet."}
Query: {query}
Search enhancement applied:
{enhanced_info}
Search results:
{results_text}
Provide a helpful, personalized response that takes into account their preferences."""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": response_prompt}],
temperature=0.7,
max_tokens=500,
)
answer = response.choices[0].message.content
store_interaction(query, answer)
return answer
def get_preference_profile() -> str:
"""Get a summary of the user's preference profile."""
profile = hindsight.reflect(
bank_id=USER_ID,
query="""Summarize what we know about this user:
- Location and neighborhood
- Dietary preferences and restrictions
- Work style and schedule
- Hobbies and interests
- Family situation
- Shopping preferences""",
budget="high",
)
return profile.text if hasattr(profile, 'text') else str(profile)
print("Helper functions defined!")
```
## 5. Build User Profile
```python
print("Learning user preferences...")
preferences = [
"Lives in San Francisco, Mission District",
"Works remotely as a software engineer",
"Vegetarian, prefers organic food when possible",
"Has a 5-year-old daughter named Emma",
"Enjoys hiking and outdoor activities on weekends",
"Prefers quiet coffee shops for remote work",
"Lactose intolerant, uses oat milk",
"Interested in sustainable and eco-friendly products",
"Usually free on Tuesday and Thursday afternoons",
"Husband is allergic to nuts",
]
for pref in preferences:
result = store_preference(pref)
print(f" {result}")
```
## 6. Personalized Search Results
```python
import time
print("=" * 60)
print(" Personalized Search Results")
print("=" * 60)
searches = [
"Find a good coffee shop for working remotely",
"Restaurant recommendations for a family dinner",
"Birthday gift ideas for a 5-year-old",
]
for query in searches:
print(f"\nSearch: {query}")
print("-" * 40)
result = personalized_search(query)
print(result)
time.sleep(1)
```
## 7. View Preference Profile
```python
print("=" * 60)
print(" User Preference Profile")
print("=" * 60)
print(get_preference_profile())
```
## 8. Try Your Own Search
```python
your_search = "Best hiking trails near me" # Change this!
print(f"Search: {your_search}")
print("-" * 40)
print(personalized_search(your_search))
```
## 9. Cleanup
```python
hindsight.close()
print("Client connection closed.")
```
@@ -0,0 +1,162 @@
---
sidebar_position: 1
---
# Hindsight Quickstart
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/01-quickstart.ipynb)
:::
This notebook covers the basics of using Hindsight:
- **Retain**: Store information in memory
- **Recall**: Retrieve memories matching a query
- **Reflect**: Generate insights from memories
## Prerequisites
Make sure you have Hindsight running. The easiest way is via Docker:
```bash
export OPENAI_API_KEY=your-key
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- API: http://localhost:8888
- UI: http://localhost:9999
## Installation
Install the Hindsight Python client:
```python
!pip install hindsight-client nest_asyncio python-dotenv -U
```
## Connect to Hindsight
```python
# Jupyter notebooks already run an asyncio event loop. The hindsight client
# uses loop.run_until_complete() internally, but Python doesn't allow nested
# event loops by default. nest_asyncio patches this to allow nesting.
import nest_asyncio
nest_asyncio.apply()
import os
from dotenv import load_dotenv
# Load environment variables from .env file
# Copy .env.example to .env and fill in your values
load_dotenv()
# Configuration (override with env vars if set)
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
from hindsight_client import Hindsight
client = Hindsight(base_url=HINDSIGHT_API_URL)
```
## Retain: Store Information
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in.
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships.
```python
# Simple retain
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
# View the stored document in the UI:
print(f"View documents: {HINDSIGHT_UI_URL}/banks/my-bank?view=documents")
```
```python
# Retain with context and timestamp
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2025-06-15T10:00:00Z"
)
```
## Recall: Retrieve Memories
The `recall` operation retrieves memories matching a query. It performs 4 retrieval strategies in parallel:
- **Semantic**: Vector similarity
- **Keyword**: BM25 exact matching
- **Graph**: Entity/temporal/causal links
- **Temporal**: Time range filtering
```python
# Simple recall
results = client.recall(bank_id="my-bank", query="What does Alice do?")
print("Memories:")
for r in results.results:
print(f" - {r.text}")
```
```python
# Temporal recall
results = client.recall(bank_id="my-bank", query="What happened in June?")
print("Memories:")
for r in results.results:
print(f" - {r.text}")
```
## Reflect: Generate Insights
The `reflect` operation performs a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations.
Example use cases:
- An AI Project Manager reflecting on what risks need to be mitigated
- A Sales Agent reflecting on why certain outreach messages have gotten responses
- A Support Agent reflecting on opportunities where customers have unanswered questions
```python
response = client.reflect(bank_id="my-bank", query="What should I know about Alice?")
print(response)
```
## Memory Types
Hindsight organizes memory into four networks to mimic human memory:
- **World**: Facts about the world ("The stove gets hot")
- **Experiences**: Agent's own experiences ("I touched the stove and it really hurt")
- **Opinion**: Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
- **Observation**: Complex mental models derived by reflecting on facts and experiences
## Cleanup
Delete the bank created during this notebook:
```python
import requests
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/my-bank")
print(f"Deleted my-bank: {response.json()}")
```
@@ -0,0 +1,335 @@
---
sidebar_position: 11
---
# Study Buddy with Hindsight Memory
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/study_buddy.ipynb)
:::
A personalized study assistant that tracks what you've learned, identifies knowledge gaps, and helps with spaced repetition.
## Features
- Tracks study sessions and topics covered
- Monitors confidence levels per topic
- Identifies knowledge gaps
- Suggests topics for spaced repetition review
## Prerequisites
- OpenAI API key
- Hindsight running locally via Docker (see setup below)
## Start Hindsight Locally
Before running this notebook, start Hindsight in a terminal:
```bash
export OPENAI_API_KEY="your-openai-api-key"
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## 1. Install Dependencies
```python
!pip install -q hindsight-client openai nest-asyncio
```
## 2. Configure OpenAI API Key
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
```python
import getpass
import os
# Set OpenAI API key (used by both Hindsight and the demo)
if not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
print("API key configured!")
```
## 3. Initialize Clients
```python
import nest_asyncio
nest_asyncio.apply()
from datetime import datetime
from openai import OpenAI
from hindsight_client import Hindsight
# Initialize Hindsight client (connects to local Docker instance)
hindsight = Hindsight(
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
USER_ID = "student-demo"
print("Clients initialized!")
```
## 4. Define Helper Functions
```python
def record_study_session(topic: str, notes: str, confidence: str = "medium") -> str:
"""Record a study session with topic, notes, and self-assessed confidence."""
today = datetime.now().strftime("%B %d, %Y")
content = f"""{today} - STUDY SESSION
Topic: {topic}
Confidence Level: {confidence}
Notes: {notes}"""
hindsight.retain(
bank_id=USER_ID,
content=content,
metadata={
"category": "study_session",
"topic": topic,
"confidence": confidence,
"date": today,
},
)
return f"Recorded study session on '{topic}' (confidence: {confidence})"
def record_question(topic: str, question: str, understood: bool) -> str:
"""Record a question asked during study."""
today = datetime.now().strftime("%B %d, %Y")
content = f"""{today} - QUESTION
Topic: {topic}
Question: {question}
Understood: {"Yes" if understood else "No - needs review"}"""
hindsight.retain(
bank_id=USER_ID,
content=content,
metadata={
"category": "question",
"topic": topic,
"understood": str(understood),
},
)
return f"Recorded question on '{topic}'"
def study_buddy(user_query: str) -> str:
"""Interact with the study buddy."""
memories = hindsight.recall(
bank_id=USER_ID,
query=f"study session topic notes questions {user_query}",
budget="high",
)
memory_context = ""
if memories and memories.results:
memory_context = "\n".join(f"- {m.text}" for m in memories.results[:8])
system_prompt = f"""You are a helpful study buddy and tutor.
You have access to the student's study history, including:
- Topics they've studied and their notes
- Their self-assessed confidence levels
- Questions they've asked and whether they understood the answers
Study History:
{memory_context if memory_context else "No study history recorded yet."}
Your role:
1. Answer questions about topics they're studying
2. Identify knowledge gaps based on their history
3. Suggest topics to review (spaced repetition)
4. Provide encouragement and study tips
5. Connect new concepts to things they've already learned
Be supportive and pedagogical. Reference their previous learning when relevant."""
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query},
],
temperature=0.7,
max_tokens=800,
)
answer = response.choices[0].message.content
hindsight.retain(
bank_id=USER_ID,
content=f"Student asked: {user_query}\nExplanation given: {answer[:300]}...",
metadata={"category": "tutoring"},
)
return answer
def get_review_suggestions() -> str:
"""Get suggestions for topics to review."""
suggestions = hindsight.reflect(
bank_id=USER_ID,
query="""Analyze this student's study history and suggest:
1. Topics with low confidence that need more review
2. Topics studied a while ago that should be revisited
3. Questions that weren't fully understood
4. Connections between topics they might have missed
Prioritize by what would most improve their understanding.""",
budget="high",
)
return suggestions.text if hasattr(suggestions, 'text') else str(suggestions)
def get_knowledge_summary(topic: str = None) -> str:
"""Get a summary of what the student knows."""
query = f"Summarize what this student knows about {topic}" if topic else \
"Summarize this student's overall knowledge and progress"
summary = hindsight.reflect(
bank_id=USER_ID,
query=query,
budget="high",
)
return summary.text if hasattr(summary, 'text') else str(summary)
print("Helper functions defined!")
```
## 5. Record Study Sessions
```python
print("Recording study sessions...")
sessions = [
{
"topic": "Classical Mechanics - Newton's Laws",
"notes": "Covered F=ma, action-reaction pairs, inertia. Solved problems on inclined planes.",
"confidence": "high",
},
{
"topic": "Classical Mechanics - Conservation of Momentum",
"notes": "Elastic vs inelastic collisions. Struggled with 2D collision problems.",
"confidence": "low",
},
{
"topic": "Classical Mechanics - Generalized Coordinates",
"notes": "Introduction to Lagrangian mechanics. Degrees of freedom concept.",
"confidence": "medium",
},
{
"topic": "Waves - Simple Harmonic Motion",
"notes": "SHM equations, period, frequency. Connected to springs and pendulums.",
"confidence": "high",
},
{
"topic": "Waves - Frequency Domain",
"notes": "Started Fourier transforms. Math is confusing, need more practice.",
"confidence": "low",
},
]
for session in sessions:
result = record_study_session(**session)
print(f" {result}")
```
## 6. Record Questions
```python
print("Recording questions...")
questions = [
("Conservation of Momentum", "Why is momentum conserved in collisions?", True),
("Conservation of Momentum", "How do I solve 2D collision problems?", False),
("Generalized Coordinates", "What's the advantage of Lagrangian over Newtonian?", True),
("Frequency Domain", "When do I use Fourier transforms vs Laplace?", False),
]
for topic, question, understood in questions:
result = record_question(topic, question, understood)
print(f" {result}")
```
## 7. Interactive Study Session
```python
import time
print("=" * 60)
print(" Study Session")
print("=" * 60)
queries = [
"Can you explain generalized coordinates again? I remember we covered it but I'm fuzzy on the details.",
"What topics should I review before my exam next week?",
"I'm still confused about 2D collision problems. Can you walk me through an example?",
]
for query in queries:
print(f"\nStudent: {query}")
print("-" * 40)
response = study_buddy(query)
print(f"Study Buddy: {response}")
time.sleep(1)
```
## 8. Get Review Suggestions
```python
print("=" * 60)
print(" Recommended Review Topics")
print("=" * 60)
print(get_review_suggestions())
```
## 9. Knowledge Summary
```python
print("=" * 60)
print(" Knowledge Summary")
print("=" * 60)
print(get_knowledge_summary())
```
## 10. Try Your Own Question
```python
your_question = "What are my biggest knowledge gaps right now?" # Change this!
print(f"You: {your_question}")
print("-" * 40)
print(f"Study Buddy: {study_buddy(your_question)}")
```
## 11. Cleanup
```python
hindsight.close()
print("Client connection closed.")
```
@@ -0,0 +1,315 @@
---
sidebar_position: 3
---
# Support Agent with Shared Knowledge
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/03-support-agent-shared-knowledge.ipynb)
:::
This pattern shows how to build a support agent that combines **per-user memory** with **shared product knowledge** (RAG), giving users personalized support while leveraging a single source of truth for documentation.
## The Problem
You're building a support agent that needs to:
- Remember each user's history, preferences, and past issues
- Access shared product documentation
- Keep user data completely isolated from other users
A naive approach would index product docs into each user's memory bank, but this is expensive and wasteful (N copies for N users).
## The Solution: Multi-Bank Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User A Bank │ │ User B Bank │ │ Shared Docs │
│ │ │ │ │ Bank │
│ - Conversations│ │ - Conversations│ │ │
│ - Preferences │ │ - Preferences │ │ - Product docs │
│ - Past issues │ │ - Past issues │ │ - FAQs │
│ - Solutions │ │ - Solutions │ │ - Guides │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┴───────────────────────┘
Agent queries
multiple banks
```
**Key benefits:**
- Product docs indexed once, shared by all users
- User memory is 100% isolated
- Simple mental model, no complex filtering
```python
!pip install hindsight-client nest_asyncio openai python-dotenv -U
```
## 1. Set Up Memory Banks
Create three types of banks:
```python
# Jupyter notebooks already run an asyncio event loop. The hindsight client
# uses loop.run_until_complete() internally, but Python doesn't allow nested
# event loops by default. nest_asyncio patches this to allow nesting.
import nest_asyncio
nest_asyncio.apply()
import os
from dotenv import load_dotenv
from openai import OpenAI as OpenAIClient
# Load environment variables from .env file
# Copy .env.example to .env and fill in your values
load_dotenv()
# Configuration (override with env vars if set)
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
from hindsight_client import Hindsight
client = Hindsight(base_url=HINDSIGHT_API_URL)
llm = OpenAIClient() # Uses OPENAI_API_KEY from .env
# Shared knowledge bank (created once)
shared_bank = client.create_bank(
bank_id="product-docs",
name="Product Documentation"
)
# Per-user banks (created when user signs up)
def create_user_bank(user_id: str):
return client.create_bank(
bank_id=f"user-{user_id}",
name=f"Memory for {user_id}"
)
```
## 2. Index Product Documentation
Index your product docs into the shared bank (do this once, or on doc updates):
```python
# Index product documentation - retain each doc separately
client.retain(
bank_id="product-docs",
content="# Pricing Tiers\n\nBasic: $10/mo, Pro: $25/mo, Enterprise: Contact us"
)
client.retain(
bank_id="product-docs",
content="# Getting Started\n\nTo set up your account, visit the dashboard and click 'New Project'"
)
# View the stored documents in the UI:
print(f"View documents: {HINDSIGHT_UI_URL}/banks/product-docs?view=documents")
```
## 3. Store User Conversations
After each support interaction, retain it in the user's bank:
```python
def save_conversation(user_id: str, messages: list):
# Convert messages to string format
content = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
client.retain(
bank_id=f"user-{user_id}",
content=content
)
```
## 4. Query Multiple Banks at Support Time
When handling a user query, retrieve context from both banks:
```python
def get_support_context(user_id: str, query: str):
# Get user's personal context
user_context = client.recall(
bank_id=f"user-{user_id}",
query=query
)
# Get relevant product documentation
docs_context = client.recall(
bank_id="product-docs",
query=query
)
return {
"user_history": user_context.results,
"documentation": docs_context.results
}
```
## 5. Build the Agent Prompt
Combine both contexts in your agent's prompt:
```python
def format_results(results):
"""Format recall results for the prompt."""
if not results:
return "No relevant information found."
return "\n".join([f"- {r.text}" for r in results])
def build_prompt(query: str, context: dict) -> str:
return f"""You are a helpful support agent.
## User's History
{format_results(context["user_history"])}
## Product Documentation
{format_results(context["documentation"])}
## Current Question
{query}
Use the user's history to personalize your response and the documentation
for accurate product information. If you find a solution, remember it for
future reference.
"""
```
## Promoting Learnings to Shared Knowledge
When the agent discovers a solution that's not in the docs, you can optionally promote it to a "learnings" bank:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User A Bank │ │ Shared Docs │ │ Learnings │
│ │ │ Bank │ │ Bank │
│ - Conversations│ │ │ │ │
│ - Preferences │ │ - Product docs │ │ - Verified │
│ - Past issues │ │ - FAQs │ │ solutions │
│ - Solutions │ │ - Guides │ │ - Workarounds │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┴───────────────────────┘
Agent queries
all three banks
```
```python
# Optional: Create a curated learnings bank
learnings_bank = client.create_bank(
bank_id="support-learnings",
name="Curated Support Learnings"
)
# After a successful resolution
def promote_learning(insight: str):
client.retain(
bank_id="support-learnings",
content=insight
)
```
## Complete Example
```python
def format_results(results):
if not results:
return "No relevant information found."
return "\n".join([f"- {r.text}" for r in results])
def handle_support_request(user_id: str, query: str):
# 1. Recall from user's memory
user_recall = client.recall(
bank_id=f"user-{user_id}",
query=query
)
# 2. Recall from shared docs
docs_recall = client.recall(
bank_id="product-docs",
query=query
)
# 3. Recall from learnings (optional)
learnings_recall = client.recall(
bank_id="support-learnings",
query=query
)
# 4. Build system prompt with context
system_prompt = f"""You are a helpful support agent. Use the context below to answer the user's question.
## User's History
{format_results(user_recall.results)}
## Product Documentation
{format_results(docs_recall.results)}
## Known Solutions
{format_results(learnings_recall.results)}
Provide helpful, accurate responses based on the documentation. Reference the user's history when relevant."""
# 5. Generate response using OpenAI
response = llm.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": query}
]
)
assistant_response = response.choices[0].message.content
# 6. Save the conversation to user's memory
conversation = f"user: {query}\nassistant: {assistant_response}"
client.retain(
bank_id=f"user-{user_id}",
content=conversation
)
return assistant_response
# Test the function
create_user_bank("bob")
print("User: How do I get started?")
result = handle_support_request("bob", "How do I get started?")
print(f"Assistant: {result}")
print(f"\nView user memory: {HINDSIGHT_UI_URL}/banks/user-bob?view=documents")
```
## When to Use This Pattern
**Good fit:**
- Support agents with shared documentation
- Multi-tenant applications with shared reference data
- Any scenario needing user isolation + shared knowledge
**Consider alternatives if:**
- You need cross-user learning (users benefiting from other users' solutions)
- Entity relationships must span across users and docs
## Cleanup
Delete the banks created during this notebook:
```python
import requests
# Delete all banks created in this notebook
for bank_id in ["product-docs", "support-learnings", "user-bob"]:
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
print(f"Deleted {bank_id}: {response.json()}")
```
@@ -0,0 +1,372 @@
---
sidebar_position: 5
---
# Routing Tool Learning
:::tip Run this notebook
This recipe is available as an interactive Jupyter notebook.
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/05-tool-learning-demo.ipynb)
:::
This notebook demonstrates how Hindsight helps an LLM learn which tool to use when tool names are ambiguous. Without memory, the LLM might randomly select between similarly-named tools. With Hindsight, it learns from past interactions and consistently makes the correct choice.
## The Scenario
We have a task routing system with two tools:
- `route_to_channel_alpha` - Routes to processing channel Alpha
- `route_to_channel_omega` - Routes to processing channel Omega
The tool names and descriptions are **intentionally vague**. In reality:
- Channel Alpha handles **FINANCIAL/PAYMENT** tasks (refunds, billing, etc.)
- Channel Omega handles **TECHNICAL/SUPPORT** tasks (bugs, features, etc.)
**Without Hindsight:** The LLM guesses randomly based on vague descriptions
**With Hindsight:** The LLM learns from feedback which channel handles what
## Prerequisites
Make sure you have Hindsight running:
```bash
export OPENAI_API_KEY=your-key
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## Installation
```python
!pip install hindsight-litellm hindsight-client litellm nest_asyncio python-dotenv -U -q
```
## Setup
```python
import os
import json
import uuid
import time
import logging
import nest_asyncio
from typing import Optional
from dotenv import load_dotenv
nest_asyncio.apply()
load_dotenv()
logging.basicConfig(level=logging.INFO)
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
import litellm
import hindsight_litellm
from hindsight_client import Hindsight
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
if not os.getenv("OPENAI_API_KEY"):
print("Warning: OPENAI_API_KEY not set")
```
## Define Tools
These tool definitions are **intentionally ambiguous** - the descriptions don't reveal which channel handles what type of request.
```python
TOOLS = [
{
"type": "function",
"function": {
"name": "route_to_channel_alpha",
"description": "Routes the customer request to processing channel Alpha. Use this channel for appropriate request types.",
"parameters": {
"type": "object",
"properties": {
"request_summary": {
"type": "string",
"description": "A brief summary of the customer's request"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Priority level of the request"
}
},
"required": ["request_summary"]
}
}
},
{
"type": "function",
"function": {
"name": "route_to_channel_omega",
"description": "Routes the customer request to processing channel Omega. Use this channel for appropriate request types.",
"parameters": {
"type": "object",
"properties": {
"request_summary": {
"type": "string",
"description": "A brief summary of the customer's request"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Priority level of the request"
}
},
"required": ["request_summary"]
}
}
}
]
```
## Test Scenarios
A mix of financial and technical requests to test routing accuracy.
```python
TEST_SCENARIOS = [
{
"type": "financial",
"request": "I was charged twice for my subscription last month. I need a refund for the duplicate charge.",
"correct_tool": "route_to_channel_alpha"
},
{
"type": "technical",
"request": "The app keeps crashing when I try to upload a file larger than 10MB. This bug is blocking my work.",
"correct_tool": "route_to_channel_omega"
},
{
"type": "financial",
"request": "My invoice shows an incorrect amount. The billing department needs to fix this.",
"correct_tool": "route_to_channel_alpha"
},
{
"type": "technical",
"request": "I'd like to request a new feature: the ability to export reports as PDF.",
"correct_tool": "route_to_channel_omega"
},
{
"type": "financial",
"request": "I need to update my payment method and understand why my last payment failed.",
"correct_tool": "route_to_channel_alpha"
},
]
```
## Helper Functions
```python
SYSTEM_PROMPT = """You are a customer service routing agent. Your job is to route customer requests to the appropriate processing channel.
You have access to two routing channels:
- route_to_channel_alpha: Routes to channel Alpha
- route_to_channel_omega: Routes to channel Omega
Analyze the customer's request and route it to the most appropriate channel. You must call one of the routing functions to process the request.
Important: Base your routing decision on what you know about each channel's purpose. If you have learned from previous interactions which channel handles specific types of requests, use that knowledge."""
def make_routing_request(user_request: str, use_hindsight: bool, bank_id: Optional[str] = None):
"""Make a routing request and return the tool called."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Customer Request: {user_request}"}
]
if use_hindsight and bank_id:
response = hindsight_litellm.completion(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS,
tool_choice="required",
temperature=0.0,
)
else:
response = litellm.completion(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS,
tool_choice="required",
temperature=0.7,
)
if response.choices[0].message.tool_calls:
tool_call = response.choices[0].message.tool_calls[0]
return tool_call.function.name
return None
def store_feedback(bank_id: str, request: str, correct_tool: str, request_type: str):
"""Store feedback about which tool was correct for a request type."""
client = Hindsight(base_url=HINDSIGHT_API_URL, timeout=60.0)
feedback_content = f"""ROUTING FEEDBACK:
Request type: {request_type}
Customer request: "{request}"
Correct routing: {correct_tool}
LEARNED RULE: {request_type.upper()} requests (like refunds, billing, payments, charges, invoices) should ALWAYS be routed to {correct_tool}.
This is important institutional knowledge for routing decisions."""
client.retain(
bank_id=bank_id,
content=feedback_content,
context=f"routing:feedback:{request_type}",
metadata={"request_type": request_type, "correct_tool": correct_tool}
)
```
## Phase 1: Without Hindsight (No Memory)
The LLM has no prior knowledge about which channel handles what. With ambiguous tool descriptions, it may route incorrectly.
```python
print("=" * 60)
print("PHASE 1: WITHOUT HINDSIGHT (No Memory)")
print("=" * 60)
phase1_results = []
for i, scenario in enumerate(TEST_SCENARIOS[:3], 1):
print(f"\n--- Test {i}: {scenario['type'].upper()} Request ---")
print(f"Request: \"{scenario['request'][:60]}...\"")
tool_name = make_routing_request(scenario['request'], use_hindsight=False)
is_correct = tool_name == scenario['correct_tool']
phase1_results.append(is_correct)
print(f"LLM chose: {tool_name}")
print(f"Correct tool: {scenario['correct_tool']}")
print(f"Result: {'✓ CORRECT' if is_correct else '✗ INCORRECT'}")
phase1_accuracy = sum(phase1_results) / len(phase1_results) * 100
print(f"\n>>> Phase 1 Accuracy: {phase1_accuracy:.0f}% ({sum(phase1_results)}/{len(phase1_results)})")
```
## Phase 2: Teaching Phase
Now we provide feedback about correct routing to build memory. This simulates a human supervisor correcting the AI's routing decisions.
```python
bank_id = f"tool-learning-{uuid.uuid4().hex[:8]}"
print(f"Using bank_id: {bank_id}")
# Configure and enable Hindsight
hindsight_litellm.configure(
hindsight_api_url=HINDSIGHT_API_URL,
bank_id=bank_id,
store_conversations=True,
inject_memories=True,
max_memories=10,
recall_budget="high",
verbose=False,
)
hindsight_litellm.enable()
print("\nStoring routing feedback...")
feedback_examples = [
("I need a refund for an incorrect charge on my account.", "route_to_channel_alpha", "financial"),
("There's a bug in the system causing data loss.", "route_to_channel_omega", "technical"),
("My billing statement has errors that need correction.", "route_to_channel_alpha", "financial"),
("I want to request a new feature for the dashboard.", "route_to_channel_omega", "technical"),
]
for request, correct_tool, req_type in feedback_examples:
print(f" Storing: {req_type.upper()} → {correct_tool}")
store_feedback(bank_id, request, correct_tool, req_type)
print("\nWaiting 15 seconds for Hindsight to process memories...")
time.sleep(15)
print("Done!")
```
## Phase 3: With Hindsight (Memory-Augmented)
The LLM now has access to learned routing knowledge via Hindsight. It should route requests correctly based on past feedback.
```python
print("=" * 60)
print("PHASE 3: WITH HINDSIGHT (Memory-Augmented)")
print("=" * 60)
phase3_results = []
for i, scenario in enumerate(TEST_SCENARIOS, 1):
print(f"\n--- Test {i}: {scenario['type'].upper()} Request ---")
print(f"Request: \"{scenario['request'][:60]}...\"")
tool_name = make_routing_request(
scenario['request'],
use_hindsight=True,
bank_id=bank_id
)
is_correct = tool_name == scenario['correct_tool']
phase3_results.append(is_correct)
print(f"LLM chose: {tool_name}")
print(f"Correct tool: {scenario['correct_tool']}")
print(f"Result: {'✓ CORRECT' if is_correct else '✗ INCORRECT'}")
phase3_accuracy = sum(phase3_results) / len(phase3_results) * 100
print(f"\n>>> Phase 3 Accuracy: {phase3_accuracy:.0f}% ({sum(phase3_results)}/{len(phase3_results)})")
```
## Summary
```python
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f"\nPhase 1 (No Memory): {phase1_accuracy:.0f}% accuracy")
print(f"Phase 3 (With Hindsight): {phase3_accuracy:.0f}% accuracy")
improvement = phase3_accuracy - phase1_accuracy
if improvement > 0:
print(f"\n🎉 Improvement: +{improvement:.0f}% accuracy with Hindsight!")
elif improvement == 0:
print(f"\nNote: Results may vary. Run again to see learning effect.")
else:
print(f"\nNote: Phase 1 got lucky! Run again to see typical behavior.")
print(f"\nMemories stored in bank: {bank_id}")
print(f"View in UI: http://localhost:9999/banks/{bank_id}")
print("\n" + "=" * 60)
print("KEY INSIGHT")
print("=" * 60)
print("Hindsight allows the LLM to learn from experience which tool")
print("to use, even when tool names/descriptions are ambiguous.")
```
## Cleanup
```python
hindsight_litellm.cleanup()
# Optional: delete the bank
import requests
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
print(f"Deleted bank: {response.json()}")
```
@@ -0,0 +1,192 @@
# Admin CLI
The `hindsight-admin` CLI provides administrative commands for managing your Hindsight deployment, including database migrations, backup, and restore operations.
## Installation
The admin CLI is included with the `hindsight-api` package:
```bash
pip install hindsight-api
# or
uv add hindsight-api
```
## Commands
### run-db-migration
Run database migrations to the latest version. This is useful when you want to run migrations separately from API startup (e.g., in CI/CD pipelines or before deploying a new version).
```bash
hindsight-admin run-db-migration [OPTIONS]
```
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema to run migrations on | `public` |
**Examples:**
```bash
# Run migrations on the default public schema
hindsight-admin run-db-migration
# Run migrations on a specific tenant schema
hindsight-admin run-db-migration --schema tenant_acme
```
:::tip Disabling Auto-Migrations
To disable automatic migrations on API startup, set `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=false`. This is useful when you want to run migrations as a separate step in your deployment pipeline.
:::
---
### backup
Create a backup of all Hindsight data to a zip file.
```bash
hindsight-admin backup OUTPUT [OPTIONS]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `OUTPUT` | Output file path (will add `.zip` extension if not present) |
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema to backup | `public` |
**Examples:**
```bash
# Backup to a file
hindsight-admin backup /backups/hindsight-2024-01-15.zip
# Backup a specific tenant schema
hindsight-admin backup /backups/tenant-acme.zip --schema tenant_acme
```
The backup includes:
- Memory banks and their configuration
- Documents and chunks
- Entities and their relationships
- Memory units (facts, experiences, observations)
- Entity cooccurrences and memory links
:::note Consistency
Backups are created within a database transaction with `REPEATABLE READ` isolation, ensuring a consistent snapshot across all tables.
:::
---
### restore
Restore data from a backup file. **Warning: This deletes all existing data in the target schema.**
```bash
hindsight-admin restore INPUT [OPTIONS]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `INPUT` | Input backup file (.zip) |
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema to restore to | `public` |
| `--yes`, `-y` | Skip confirmation prompt | `false` |
**Examples:**
```bash
# Restore with confirmation prompt
hindsight-admin restore /backups/hindsight-2024-01-15.zip
# Restore without confirmation (for scripts)
hindsight-admin restore /backups/hindsight-2024-01-15.zip --yes
# Restore to a specific tenant schema
hindsight-admin restore /backups/tenant-acme.zip --schema tenant_acme --yes
```
:::warning Data Loss
Restore will **delete all existing data** in the target schema before importing the backup. Always verify you have a recent backup before performing a restore.
:::
---
### decommission-worker
Release all tasks owned by a worker, resetting them from "processing" back to "pending" status so they can be picked up by other workers.
```bash
hindsight-admin decommission-worker WORKER_ID [OPTIONS]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `WORKER_ID` | ID of the worker to decommission |
**Options:**
| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema | `public` |
**Examples:**
```bash
# Before scaling down - release tasks from workers being removed
hindsight-admin decommission-worker hindsight-worker-4
hindsight-admin decommission-worker hindsight-worker-3
# Release tasks from a crashed worker
hindsight-admin decommission-worker worker-2
# For a specific tenant schema
hindsight-admin decommission-worker worker-1 --schema tenant_acme
```
**When to Use:**
- **Scaling down**: Before removing worker replicas in Kubernetes
- **Graceful removal**: When taking a worker offline for maintenance
- **Crash recovery**: If a worker crashed while processing tasks
- **Stuck worker**: When a worker is unresponsive
:::tip Finding Worker IDs
Worker IDs default to the hostname. In Kubernetes StatefulSets, this is the pod name (e.g., `hindsight-worker-0`). You can also set a custom ID with `HINDSIGHT_API_WORKER_ID` or `--worker-id`.
:::
---
## Environment Variables
The admin CLI uses the same environment variables as the API service. The most important one is:
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
**Example:**
```bash
# Use a specific database
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
hindsight-admin backup /backups/mybackup.zip
```
@@ -0,0 +1,245 @@
# Documents
Track and manage document sources in your memory bank. Documents provide traceability — knowing where memories came from.
{/* Import raw source files */}
> **💡 Prerequisites**
>
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
## What Are Documents?
Documents are containers for retained content. They help you:
- **Track sources** — Know which PDF, conversation, or file a memory came from
- **Update content** — Re-retain a document to update its facts
- **Delete in bulk** — Remove all memories from a document at once
- **Organize memories** — Group related facts by source
## Chunks
When you retain content, Hindsight splits it into chunks before extracting facts. These chunks are stored alongside the extracted memories, preserving the original text segments.
**Why chunks matter:**
- **Context preservation** — Chunks contain the raw text that generated facts, useful when you need the exact wording
- **Richer recall** — Including chunks in recall provides surrounding context for matched facts
> **💡 Include Chunks in Recall**
>
Use `include_chunks=True` in your recall calls to get the original text chunks alongside fact results. See [Recall](./recall) for details.
## Retain with Document ID
Associate retained content with a document:
### Python
```python
# Retain with document ID
client.retain(
bank_id="my-bank",
content="Alice presented the Q4 roadmap...",
document_id="meeting-2024-03-15"
)
# Batch retain for a document
client.retain_batch(
bank_id="my-bank",
items=[
{"content": "Item 1: Product launch delayed to Q2"},
{"content": "Item 2: New hiring targets announced"},
{"content": "Item 3: Budget approved for ML team"}
],
document_id="meeting-2024-03-15"
)
```
### Node.js
```javascript
// Retain with document ID
await client.retain('my-bank', 'Alice presented the Q4 roadmap...', {
document_id: 'meeting-2024-03-15'
});
// Batch retain
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' });
```
### CLI
```bash
# Retain content with document ID
hindsight memory retain my-bank "Meeting notes content..." --doc-id notes-2024-03-15
# Batch retain from files
hindsight memory retain-files my-bank docs/
```
## Update Documents
Re-retaining with the same document_id **replaces** the old content:
### Python
```python
# Original
client.retain(
bank_id="my-bank",
content="Project deadline: March 31",
document_id="project-plan"
)
# Update (deletes old facts, creates new ones)
client.retain(
bank_id="my-bank",
content="Project deadline: April 15 (extended)",
document_id="project-plan"
)
```
### Node.js
```javascript
// Original
await client.retain('my-bank', 'Project deadline: March 31', {
document_id: 'project-plan'
});
// Update
await client.retain('my-bank', 'Project deadline: April 15 (extended)', {
document_id: 'project-plan'
});
```
### CLI
```bash
# Original
hindsight memory retain my-bank "Project deadline: March 31" --doc-id project-plan
# Update
hindsight memory retain my-bank "Project deadline: April 15 (extended)" --doc-id project-plan
```
## Get Document
Retrieve a document's original text and metadata. This is useful for expanding document context after a recall operation returns memories with document references.
### Python
```python
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
async def get_document_example():
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DocumentsApi(api_client)
# Get document to expand context from recall results
doc = await 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}")
print(f"Memory count: {doc.memory_unit_count}")
print(f"Created: {doc.created_at}")
asyncio.run(get_document_example())
```
### Node.js
```javascript
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
// Get document to expand context from recall results
const { data: doc } = await sdk.getDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
});
console.log(`Document: ${doc.id}`);
console.log(`Original text: ${doc.original_text}`);
console.log(`Memory count: ${doc.memory_unit_count}`);
console.log(`Created: ${doc.created_at}`);
```
### CLI
```bash
hindsight document get my-bank meeting-2024-03-15
```
## Delete Document
Remove a document and all its associated memories:
### Python
```python
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
async def delete_document_example():
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DocumentsApi(api_client)
# Delete document and all its memories
result = await api.delete_document(
bank_id="my-bank",
document_id="meeting-2024-03-15"
)
print(f"Deleted {result.memory_units_deleted} memories")
asyncio.run(delete_document_example())
```
### Node.js
```javascript
// Delete document and all its memories
const { data: deleteResult } = await sdk.deleteDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
});
console.log(`Deleted ${deleteResult.memory_units_deleted} memories`);
```
### CLI
```bash
hindsight document delete my-bank meeting-2024-03-15
```
:::warning
Deleting a document permanently removes all memories extracted from it. This action cannot be undone.
## Document Response Format
```json
{
"id": "meeting-2024-03-15",
"bank_id": "my-bank",
"original_text": "Alice presented the Q4 roadmap...",
"content_hash": "abc123def456",
"memory_unit_count": 12,
"created_at": "2024-03-15T14:00:00Z",
"updated_at": "2024-03-15T14:00:00Z"
}
```
## Next Steps
- [**Operations**](./operations) — Monitor background tasks
- [**Memory Banks**](./memory-banks) — Configure bank settings
@@ -0,0 +1,272 @@
# Main Methods
Hindsight provides three core operations: **retain**, **recall**, and **reflect**.
{/* Import raw source files */}
> **💡 Prerequisites**
>
Make sure you've [installed Hindsight](../installation) and completed the [Quick Start](./quickstart).
## Retain: Store Information
Store conversations, documents, and facts into a memory bank.
### Python
```python
# Store a single fact
client.retain(
bank_id="my-bank",
content="Alice joined Google in March 2024 as a Senior ML Engineer"
)
# Store a conversation
conversation = """
User: What did you work on today?
Assistant: I reviewed the new ML pipeline architecture.
User: How did it look?
Assistant: Promising, but needs better error handling.
"""
client.retain(
bank_id="my-bank",
content=conversation,
context="Daily standup conversation"
)
# Batch retain multiple items
client.retain_batch(
bank_id="my-bank",
items=[
{"content": "Bob prefers Python for data science"},
{"content": "Alice recommends using pytest for testing"},
{"content": "The team uses GitHub for code reviews"}
]
)
```
### Node.js
```javascript
// Store a single fact
await client.retain('my-bank', 'Alice joined Google in March 2024 as a Senior ML Engineer');
// Store a conversation
const conversation = `
User: What did you work on today?
Assistant: I reviewed the new ML pipeline architecture.
User: How did it look?
Assistant: Promising, but needs better error handling.
`;
await client.retain('my-bank', conversation, {
context: 'Daily standup conversation'
});
// Batch retain multiple items
await client.retainBatch('my-bank', [
{ content: 'Bob prefers Python for data science' },
{ content: 'Alice recommends using pytest for testing' },
{ content: 'The team uses GitHub for code reviews' }
]);
```
### CLI
```bash
# Store a single fact
hindsight retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
# Store from a file
hindsight retain my-bank --file conversation.txt --context "Daily standup"
# Store multiple files
hindsight retain my-bank --files docs/*.md
```
**What happens:** Content is processed by an LLM to extract rich facts, identify entities, and build connections in a knowledge graph.
**See:** [Retain Details](./retain) for advanced options and parameters.
---
## Recall: Search Memories
Search for relevant memories using multi-strategy retrieval.
### Python
```python
# Basic search
results = client.recall(
bank_id="my-bank",
query="What does Alice do at Google?"
)
for result in results.results:
print(f"- {result.text}")
# Search with options
results = client.recall(
bank_id="my-bank",
query="What happened last spring?",
budget="high", # More thorough graph traversal
max_tokens=8192, # Return more context
types=["world"] # Only world facts
)
# Include source chunks for more context
results = client.recall(
bank_id="my-bank",
query="Tell me about Alice",
include_chunks=True,
max_chunk_tokens=500
)
# Check chunk details (chunks are on response level, keyed by memory ID)
for result in results.results:
print(f"Memory: {result.text}")
if results.chunks and result.id in results.chunks:
chunk = results.chunks[result.id]
print(f" Source: {chunk.text[:100]}...")
```
### Node.js
```javascript
// Basic search
const results = await client.recall('my-bank', 'What does Alice do at Google?');
for (const result of results.results) {
console.log(`- ${result.text}`);
}
// Search with options
const filteredResults = await client.recall('my-bank', 'What happened last spring?', {
budget: 'high',
maxTokens: 8192,
types: ['world']
});
// Include entity information
const entityResults = await client.recall('my-bank', 'Tell me about Alice', {
includeEntities: true,
maxEntityTokens: 500
});
// Check entity details
for (const [entityId, entity] of Object.entries(entityResults.entities || {})) {
console.log(`Entity: ${entity.canonical_name}`);
console.log(`Observations: ${entity.observations}`);
}
```
### CLI
```bash
# Basic search
hindsight recall my-bank "What does Alice do at Google?"
# Search with options
hindsight recall my-bank "What happened last spring?" \
--budget high \
--max-tokens 8192 \
--fact-type world
# Verbose output (shows weights and sources)
hindsight recall my-bank "Tell me about Alice" -v
```
**What happens:** Four search strategies (semantic, keyword, graph, temporal) run in parallel, results are fused and reranked.
**See:** [Recall Details](./recall) for tuning quality vs latency.
---
## Reflect: Reason with Disposition
Generate disposition-aware responses using memories and observations.
### Python
```python
# Basic reflect
response = client.reflect(
bank_id="my-bank",
query="Should we adopt TypeScript for our backend?"
)
print(response.text)
print("\nBased on:", len(response.based_on or []), "facts")
# Reflect with options
response = client.reflect(
bank_id="my-bank",
query="What are Alice's strengths for the team lead role?",
budget="high" # More thorough reasoning
)
# See which facts influenced the response
for fact in response.based_on or []:
print(f"- {fact.text}")
```
### Node.js
```javascript
// Basic reflect
const response = await client.reflect('my-bank', 'Should we adopt TypeScript for our backend?');
console.log(response.text);
console.log('\nBased on:', (response.based_on || []).length, 'facts');
// Reflect with options
const detailedResponse = await client.reflect('my-bank', "What are Alice's strengths for the team lead role?", {
budget: 'high'
});
// See which facts influenced the response
for (const fact of detailedResponse.based_on || []) {
console.log(`- ${fact.text}`);
}
```
### CLI
```bash
# Basic reflect
hindsight reflect my-bank "Should we adopt TypeScript for our backend?"
# Verbose output (shows sources and observations)
hindsight reflect my-bank "What are Alice's strengths for the team lead role?" -v
# With higher reasoning budget
hindsight reflect my-bank "Analyze our tech stack" --budget high
```
**What happens:** Memories and observations are recalled, bank disposition is applied, and the LLM reasons through the evidence to generate a response.
**See:** [Reflect Details](./reflect) for disposition configuration.
---
## Comparison
| Feature | Retain | Recall | Reflect |
|---------|--------|--------|---------|
| **Purpose** | Store information | Find information | Reason about information |
| **Input** | Raw text/documents | Search query | Question/prompt |
| **Output** | Memory IDs | Ranked facts + observations | Reasoned response |
| **Uses LLM** | Yes (extraction) | No | Yes (generation) |
| **Uses observations** | No | Yes | Yes |
| **Disposition** | No | No | Yes |
---
## Next Steps
- [**Retain**](./retain) — Advanced options for storing memories
- [**Recall**](./recall) — Tuning search quality and performance
- [**Reflect**](./reflect) — Configuring disposition
- [**Memory Banks**](./memory-banks) — Managing memory bank disposition
@@ -0,0 +1,229 @@
# Memory Banks
Memory banks are isolated containers that store all memory-related data for a specific context or use case.
{/* Import raw source files */}
## What is a Memory Bank?
A memory bank is a complete, isolated storage unit containing:
- **Memories** — Facts and information retained from conversations
- **Documents** — Files and content indexed for retrieval
- **Entities** — People, places, concepts extracted from memories
- **Relationships** — Connections between entities in the knowledge graph
- **Directives** — Hard rules the agent must follow during reflect operations
Banks are completely isolated from each other — memories stored in one bank are not visible to another.
You don't need to pre-create a bank. Hindsight will automatically create it with default settings when you first use it.
> **💡 Prerequisites**
>
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
## Creating a Memory Bank
### Python
```python
client.create_bank(
bank_id="my-bank",
name="Research Assistant",
mission="You're a research assistant specializing in machine learning - keep track of papers, methods, and findings.",
disposition={
"skepticism": 4,
"literalism": 3,
"empathy": 3
}
)
```
### Node.js
```javascript
await client.createBank('my-bank', {
name: 'Research Assistant',
mission: 'I am a research assistant specializing in machine learning',
disposition: {
skepticism: 4,
literalism: 3,
empathy: 3
}
});
```
### CLI
```bash
# Set mission
hindsight bank mission my-bank "I am a research assistant specializing in ML"
# Set disposition
hindsight bank disposition my-bank \
--skepticism 4 \
--literalism 3 \
--empathy 3
```
## Mission and Disposition
Mission and disposition are optional settings that influence how the bank reasons during [reflect](./reflect) operations.
:::info
Mission and disposition only affect the `reflect` operation. They do not impact `retain`, `recall`, or other memory operations.
### Mission
The mission is a first-person narrative providing context for reasoning:
### Python
```python
client.create_bank(
bank_id="financial-advisor",
name="Financial Advisor",
mission="""You're a conservative financial advisor - keep track of client risk tolerance,
investment preferences, and market conditions. Prioritize capital preservation over growth."""
)
```
### Node.js
```javascript
await client.createBank('financial-advisor', {
name: 'Financial Advisor',
mission: `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.`
});
```
### Disposition Traits
Disposition traits influence how reasoning is performed during reflection. Each trait is scored 1 to 5:
| Trait | Low (1) | High (5) |
|-------|---------|----------|
| **Skepticism** | Trusting, accepts information at face value | Skeptical, questions and doubts claims |
| **Literalism** | Flexible interpretation, reads between the lines | Literal interpretation, takes things exactly as stated |
| **Empathy** | Detached, focuses on facts and logic | Empathetic, considers emotional context |
## Directives
Directives are hard rules that the agent must follow during [reflect](./reflect) operations. Unlike disposition traits which influence *how* the agent reasons, directives are explicit instructions that are *always* enforced.
:::info
Directives only affect the `reflect` operation. They are injected into prompts and the agent is required to comply with them in all responses.
### When to Use Directives
Use directives for rules that must never be violated:
- **Language/style constraints**: "Always respond in formal English"
- **Privacy rules**: "Never share personal data with third parties"
- **Domain constraints**: "Prefer conservative investment recommendations"
- **Behavioral guardrails**: "Always cite sources when making claims"
### Creating Directives
### Python
```python
# Create a directive (hard rule for reflect)
directive = client.create_directive(
bank_id=BANK_ID,
name="Formal Language",
content="Always respond in formal English, avoiding slang and colloquialisms."
)
print(f"Created directive: {directive.id}")
```
### Node.js
```javascript
// Create a directive (hard rule for reflect)
const directive = await client.createDirective(
BANK_ID,
'Formal Language',
'Always respond in formal English, avoiding slang and colloquialisms.'
);
console.log(`Created directive: ${directive.id}`);
```
### Listing Directives
### Python
```python
# List all directives in a bank
directives = client.list_directives(bank_id=BANK_ID)
for d in directives.items:
print(f"- {d.name}: {d.content[:50]}...")
```
### Node.js
```javascript
// List all directives in a bank
const directives = await client.listDirectives(BANK_ID);
for (const d of directives.items) {
console.log(`- ${d.name}: ${d.content.slice(0, 50)}...`);
}
```
### Updating Directives
### Python
```python
# Update a directive (e.g., disable without deleting)
updated = client.update_directive(
bank_id=BANK_ID,
directive_id=directive_id,
is_active=False
)
print(f"Directive active: {updated.is_active}")
```
### Node.js
```javascript
// Update a directive (e.g., disable without deleting)
const updated = await client.updateDirective(BANK_ID, directiveId, {
isActive: false
});
console.log(`Directive active: ${updated.is_active}`);
```
### Deleting Directives
### Python
```python
# Delete a directive
client.delete_directive(
bank_id=BANK_ID,
directive_id=directive_id
)
```
### Node.js
```javascript
// Delete a directive
await client.deleteDirective(BANK_ID, directiveId);
```
### Directives vs Disposition
| Aspect | Directives | Disposition |
|--------|------------|-------------|
| **Nature** | Hard rules, must be followed | Soft influence on reasoning style |
| **Enforcement** | Strict — responses are rejected if violated | Flexible — shapes interpretation |
| **Use case** | Compliance, guardrails, constraints | Personality, character, tone |
| **Example** | "Never recommend specific stocks" | High skepticism: questions claims |
@@ -0,0 +1,267 @@
# Mental Models
User-curated summaries that provide high-quality, pre-computed answers for common queries.
{/* Import raw source files */}
## What Are Mental Models?
Mental models are **saved reflect responses** that you curate for your memory bank. When you create a mental model, Hindsight runs a reflect operation with your source query and stores the result. During future reflect calls, these pre-computed summaries are checked first — providing faster, more consistent answers.
```mermaid
graph LR
A[Create Mental Model] --> B[Run Reflect]
B --> C[Store Result]
C --> D[Future Queries]
D --> E{Match Found?}
E -->|Yes| F[Return Mental Model]
E -->|No| G[Run Full Reflect]
```
### Why Use Mental Models?
| Benefit | Description |
|---------|-------------|
| **Consistency** | Same answer every time for common questions |
| **Speed** | Pre-computed responses are returned instantly |
| **Quality** | Manually curated summaries you've reviewed |
| **Control** | Define exactly how key topics should be answered |
### Hierarchical Retrieval
During reflect, the agent checks sources in priority order:
1. **Mental Models** — User-curated summaries (highest priority)
2. **Observations** — Consolidated knowledge
3. **Raw Facts** — Ground truth memories
Mental models are checked first because they represent your explicitly curated knowledge.
---
## Create a Mental Model
Creating a mental model runs a reflect operation in the background and saves the result:
### Python
```python
# Create a mental model (runs reflect in background)
result = client.create_mental_model(
bank_id=BANK_ID,
name="Team Communication Preferences",
source_query="How does the team prefer to communicate?",
tags=["team", "communication"]
)
# Returns an operation_id - check operations endpoint for completion
print(f"Operation ID: {result.operation_id}")
```
### CLI
```bash
# Create a mental model (async operation)
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
-H "Content-Type: application/json" \
-d '{
"name": "Team Communication Preferences",
"source_query": "How does the team prefer to communicate?",
"tags": ["team"]
}'
# Response: {"operation_id": "op-123"}
# Use the operations endpoint to check completion
```
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Human-readable name for the mental model |
| `source_query` | string | Yes | The query to run to generate content |
| `tags` | list | No | Tags for filtering during retrieval |
| `max_tokens` | int | No | Maximum tokens for the mental model content |
| `trigger` | object | No | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |
---
## Automatic Refresh
Mental models can be configured to **automatically refresh** when observations are updated. This keeps them in sync with the latest knowledge without manual intervention.
### Trigger Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `refresh_after_consolidation` | bool | false | Automatically refresh after observations consolidation |
When `refresh_after_consolidation` is enabled, the mental model will be re-generated every time the bank's observations are consolidated — ensuring it always reflects the latest synthesized knowledge.
### Python
```python
# Create a mental model with automatic refresh enabled
result = client.create_mental_model(
bank_id=BANK_ID,
name="Project Status",
source_query="What is the current project status?",
trigger={"refresh_after_consolidation": True}
)
# This mental model will automatically refresh when observations are updated
print(f"Operation ID: {result.operation_id}")
```
### CLI
```bash
# Create a mental model with automatic refresh enabled
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
-H "Content-Type: application/json" \
-d '{
"name": "Project Status",
"source_query": "What is the current project status?",
"trigger": {"refresh_after_consolidation": true}
}'
```
### When to Use Automatic Refresh
| Use Case | Automatic Refresh | Why |
|----------|-------------------|-----|
| **Real-time dashboards** | ✅ Enabled | Status should always be current |
| **Policy summaries** | ❌ Disabled | Policies change infrequently, manual refresh preferred |
| **User preferences** | ✅ Enabled | Preferences evolve with new interactions |
| **FAQ answers** | ❌ Disabled | Answers are curated, should be reviewed before updating |
:::tip
Enable automatic refresh for mental models that need to stay current. Disable it for curated content where you want to review changes before they go live.
---
## List Mental Models
### Python
```python
# List all mental models in a bank
mental_models = client.list_mental_models(bank_id=BANK_ID)
for mental_model in mental_models.items:
print(f"- {mental_model.name}: {mental_model.source_query}")
```
### CLI
```bash
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
```
---
## Get a Mental Model
### Python
```python
# Section 'get-mental-model' not found in api/mental-models.py
```
### CLI
```bash
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
```
### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique mental model ID |
| `bank_id` | string | Memory bank ID |
| `name` | string | Human-readable name |
| `source_query` | string | The query used to generate content |
| `content` | string | The generated mental model text |
| `tags` | list | Tags for filtering |
| `last_refreshed_at` | string | When the mental model was last updated |
| `created_at` | string | When the mental model was created |
| `reflect_response` | object | Full reflect response including `based_on` facts |
---
## Refresh a Mental Model
Re-run the source query to update the mental model with current knowledge:
### Python
```python
# Section 'refresh-mental-model' not found in api/mental-models.py
```
### CLI
```bash
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}/refresh"
```
Refreshing is useful when:
- New memories have been retained that affect the topic
- Observations have been updated
- You want to ensure the mental model reflects current knowledge
---
## Update a Mental Model
Update the mental model's name:
### Python
```python
# Section 'update-mental-model' not found in api/mental-models.py
```
### CLI
```bash
curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}" \
-H "Content-Type: application/json" \
-d '{"name": "Updated Team Communication Preferences"}'
```
---
## Delete a Mental Model
### Python
```python
# Section 'delete-mental-model' not found in api/mental-models.py
```
### CLI
```bash
curl -X DELETE "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
```
---
## Use Cases
| Use Case | Example |
|----------|---------|
| **FAQ Answers** | Pre-compute answers to common customer questions |
| **Onboarding Summaries** | "What should new team members know?" |
| **Status Reports** | "What's the current project status?" refreshed weekly |
| **Policy Summaries** | "What are our security policies?" |
---
## Next Steps
- [**Reflect**](./reflect) — How the agentic loop uses mental models
- [**Observations**](/developer/observations) — How knowledge is consolidated
- [**Operations**](./operations) — Track async mental model creation
@@ -0,0 +1,94 @@
---
sidebar_position: 9
---
# Operations
Background tasks that Hindsight executes asynchronously.
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
:::
## How Operations Work
Hindsight processes several types of tasks in the background to maintain memory quality and consistency. These operations run automatically—you don't need to trigger them manually.
By default, all background operations are executed in-process within the API service.
:::note Kafka Integration
Support for external streaming platforms like Kafka for scale-out processing is planned but **not available out of the box** in the current release.
:::
## Operation Types
| Operation | Trigger | Description |
|-----------|---------|-------------|
| **batch_retain** | `retain_batch` with `async=True` | Processes large content batches in the background |
| **consolidate** | After `retain` | Consolidates new facts into observations |
## Async Retain Example
When retaining large batches of memories, use `async=true` to process in the background. The response includes an `operation_id` that you can use to poll for completion.
### 1. Submit async retain request
```bash
curl -X POST "http://localhost:8000/v1/default/banks/my-bank/memories" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"content": "Alice joined Google in 2023"},
{"content": "Bob prefers Python over JavaScript"}
],
"async": true
}'
```
Response:
```json
{
"success": true,
"bank_id": "my-bank",
"items_count": 2,
"async": true,
"operation_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
### 2. Poll for operation status
```bash
curl "http://localhost:8000/v1/default/banks/my-bank/operations"
```
Response:
```json
{
"bank_id": "my-bank",
"operations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"task_type": "retain",
"items_count": 2,
"document_id": null,
"created_at": "2024-01-15T10:30:00Z",
"status": "completed",
"error_message": null
}
]
}
```
### Operation Status Values
| Status | Description |
|--------|-------------|
| `pending` | Operation is queued and waiting to be processed |
| `completed` | Operation finished successfully |
| `failed` | Operation failed (check `error_message` for details) |
## Next Steps
- [**Documents**](./documents) — Track document sources
- [**Memory Banks**](./memory-banks) — Configure bank settings
@@ -0,0 +1,122 @@
# Quick Start
Get up and running with Hindsight in 60 seconds.
{/* Import raw source files */}
## Start the API Server
### pip (API only)
```bash
pip install hindsight-api
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY
hindsight-api
```
API available at [http://localhost:8888](http://localhost:8888/docs)
### Docker (Full Experience)
```bash
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API**: http://localhost:8888
- **Control Plane** (Web UI): http://localhost:9999
> **💡 LLM Provider**
>
Hindsight requires an LLM with structured output support. Recommended: **Groq** with `gpt-oss-20b` for fast, cost-effective inference.
See [LLM Providers](/developer/models#llm) for more details.
---
## Use the Client
### Python
```bash
pip install hindsight-client
```
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Retain: Store information
client.retain(bank_id="my-bank", content="Alice works at Google as a software engineer")
# Recall: Search memories
client.recall(bank_id="my-bank", query="What does Alice do?")
# Reflect: Generate disposition-aware response
client.reflect(bank_id="my-bank", query="Tell me about Alice")
```
### Node.js
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
// Retain: Store information
await client.retain('my-bank', 'Alice works at Google as a software engineer');
// Recall: Search memories
await client.recall('my-bank', 'What does Alice do?');
// Reflect: Generate response
await client.reflect('my-bank', 'Tell me about Alice');
```
### CLI
```bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```
```bash
# Retain: Store information
hindsight memory retain my-bank "Alice works at Google as a software engineer"
# Recall: Search memories
hindsight memory recall my-bank "What does Alice do?"
# Reflect: Generate response
hindsight memory reflect my-bank "Tell me about Alice"
```
---
## What's Happening
| Operation | What it does |
|-----------|--------------|
| **Retain** | Content is processed, facts are extracted, entities are identified and linked in a knowledge graph |
| **Recall** | Four search strategies (semantic, keyword, graph, temporal) run in parallel to find relevant memories |
| **Reflect** | Retrieved memories are used to generate a disposition-aware response |
---
## Next Steps
- [**Retain**](./retain) — Advanced options for storing memories
- [**Recall**](./recall) — Search and retrieval strategies
- [**Reflect**](./reflect) — Disposition-aware reasoning
- [**Memory Banks**](./memory-banks) — Configure disposition and mission
- [**Server Deployment**](/developer/installation) — Docker Compose, Helm, and production setup
@@ -0,0 +1,235 @@
# Recall Memories
Retrieve memories using multi-strategy recall.
{/* Import raw source files */}
:::info How Recall Works
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
> **💡 Prerequisites**
>
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
## Basic Recall
### Python
```python
response = client.recall(bank_id="my-bank", query="What does Alice do?")
for r in response.results:
print(f"- {r.text}")
```
### Node.js
```javascript
const response = await client.recall('my-bank', 'What does Alice do?');
for (const r of response.results) {
console.log(`${r.text} (score: ${r.weight})`);
}
```
### CLI
```bash
hindsight memory recall my-bank "What does Alice do?"
```
## Recall Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | string | required | Natural language query |
| `types` | list | all | Filter: `world`, `experience`, `observation` |
| `budget` | string | "mid" | Budget level: `low`, `mid`, `high` |
| `max_tokens` | int | 4096 | Token budget for results |
| `trace` | bool | false | Enable trace output for debugging |
| `include_chunks` | bool | false | Include raw text chunks that generated the memories |
| `max_chunk_tokens` | int | 500 | Token budget for chunks |
| `tags` | list | None | Filter memories by tags (see [Tag Filtering](#filter-by-tags)) |
| `tags_match` | string | "any" | How to match tags: `any`, `all`, `any_strict`, `all_strict` |
### Python
```python
response = client.recall(
bank_id="my-bank",
query="What does Alice do?",
types=["world", "experience"],
budget="high",
max_tokens=8000,
trace=True,
)
# Access results
for r in response.results:
print(f"- {r.text}")
```
### Node.js
```javascript
const detailedResponse = await client.recall('my-bank', 'What does Alice do?', {
types: ['world', 'experience'],
budget: 'high',
maxTokens: 8000,
trace: true
});
// Access results
for (const r of detailedResponse.results) {
console.log(`${r.text} (score: ${r.weight})`);
}
```
## Filter by Fact Type
Recall specific memory types:
### Python
```python
# Only world facts (objective information)
world_facts = client.recall(
bank_id="my-bank",
query="Where does Alice work?",
types=["world"]
)
```
```python
# Only experience (conversations and events)
experience = client.recall(
bank_id="my-bank",
query="What have I recommended?",
types=["experience"]
)
```
```python
# Only observations (consolidated knowledge)
observations = client.recall(
bank_id="my-bank",
query="What patterns have I learned?",
types=["observation"]
)
```
### CLI
```bash
hindsight memory recall my-bank "query" --fact-type world,observation
```
> **💡 About Observations**
>
Observations are consolidated knowledge synthesized from multiple facts. They capture patterns, preferences, and learnings that the memory bank has built up over time. Observations are automatically created in the background after retain operations.
## Token Budget Management
Hindsight is built for AI agents, not humans. Traditional retrieval systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.
The `max_tokens` parameter lets you control how much of your agent's context budget to spend on memories:
### Python
```python
# Fill up to 4K tokens of context with relevant memories
results = client.recall(bank_id="my-bank", query="What do I know about Alice?", max_tokens=4096)
# Smaller budget for quick lookups
results = client.recall(bank_id="my-bank", query="Alice's email", max_tokens=500)
```
This design means you never have to guess whether 10 results or 50 results will fit your context. Just specify the token budget and Hindsight returns as many relevant memories as will fit.
## Budget Levels
The `budget` parameter controls graph traversal depth:
- **"low"**: Fast, shallow retrieval — good for simple lookups
- **"mid"**: Balanced — default for most queries
- **"high"**: Deep exploration — finds indirect connections
### Python
```python
# Quick lookup
results = client.recall(bank_id="my-bank", query="Alice's email", budget="low")
# Deep exploration
results = client.recall(bank_id="my-bank", query="How are Alice and Bob connected?", budget="high")
```
### Node.js
```javascript
// Quick lookup
const quickResults = await client.recall('my-bank', "Alice's email", { budget: 'low' });
// Deep exploration
const deepResults = await client.recall('my-bank', 'How are Alice and Bob connected?', { budget: 'high' });
```
## Filter by Tags
Tags enable **visibility scoping**—filter memories based on tags assigned during [retain](./retain#tagging-memories). This is essential for multi-user agents where each user should only see their own memories.
### Basic Tag Filtering
### Python
```python
# Filter recall to only memories tagged for a specific user
response = client.recall(
bank_id="my-bank",
query="What feedback did the user give?",
tags=["user:alice"],
tags_match="any" # OR matching, includes untagged (default)
)
```
### Tag Match Modes
The `tags_match` parameter controls how tags are matched:
| Mode | Behavior | Untagged Memories |
|------|----------|-------------------|
| `any` | OR: memory has ANY of the specified tags | **Included** |
| `all` | AND: memory has ALL of the specified tags | **Included** |
| `any_strict` | OR: memory has ANY of the specified tags | **Excluded** |
| `all_strict` | AND: memory has ALL of the specified tags | **Excluded** |
**Strict modes** are useful when you want to ensure only tagged memories are returned:
### Python
```python
# Strict mode: only return memories that have matching tags (exclude untagged)
response = client.recall(
bank_id="my-bank",
query="What did the user say?",
tags=["user:alice"],
tags_match="any_strict" # OR matching, excludes untagged memories
)
```
**AND matching** requires all specified tags to be present:
### Python
```python
# AND matching: require ALL specified tags to be present
response = client.recall(
bank_id="my-bank",
query="What bugs were reported?",
tags=["user:alice", "bug-report"],
tags_match="all_strict" # Memory must have BOTH tags
)
```
### Use Cases
| Scenario | Tags | Mode | Result |
|----------|------|------|--------|
| User A's memories only | `["user:alice"]` | `any_strict` | Only memories tagged `user:alice` |
| Support + feedback | `["support", "feedback"]` | `any` | Memories with either tag + untagged |
| Multi-user room | `["user:alice", "room:general"]` | `all_strict` | Only memories with both tags |
| Global + user-specific | `["user:alice"]` | `any` | Alice's memories + shared (untagged) |
@@ -0,0 +1,274 @@
# Reflect
Generate disposition-aware responses using an agentic reasoning loop.
When you call **reflect**, Hindsight runs an **agentic loop** that:
1. **Autonomously searches** for relevant information using multiple tools
2. **Applies** the bank's disposition traits to shape the reasoning style
3. **Generates** a grounded answer with citations to the sources used
The agent has access to hierarchical retrieval tools (mental models → observations → raw facts) and decides what information it needs to answer your query.
{/* Import raw source files */}
:::info How Reflect Works
Learn about disposition-driven reasoning in the [Reflect Architecture](/developer/reflect) guide.
> **💡 Prerequisites**
>
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
## Basic Usage
### Python
```python
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
### Node.js
```javascript
await client.reflect('my-bank', 'What should I know about Alice?');
```
### CLI
```bash
hindsight memory reflect my-bank "What do you know about Alice?"
```
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | string | required | Question or prompt |
| `budget` | string | "low" | Budget level: `low`, `mid`, `high` (see below) |
| `max_tokens` | int | 4096 | Maximum tokens for the final response |
| `response_schema` | object | None | JSON Schema for [structured output](#structured-output) |
| `tags` | list | None | Filter memories by tags during reflection |
| `tags_match` | string | "any" | How to match tags: `any`, `all`, `any_strict`, `all_strict` |
| `trace` | bool | false | Include detailed agent trace in response |
### Budget
The `budget` parameter controls the research depth — how thoroughly the agent explores before answering:
| Budget | Research Depth | Use Case |
|--------|----------------|----------|
| `low` | Shallow | Quick answers, simple lookups. Prioritizes speed over completeness. |
| `mid` | Moderate | Balanced exploration. Checks multiple sources when warranted. |
| `high` | Deep | Comprehensive analysis. Explores all knowledge levels, uses multiple query variations. |
Use `high` for complex questions that require synthesizing information from multiple sources or verifying facts across different retrieval levels.
### Max Tokens
The `max_tokens` parameter limits the length of the final generated response. This does not affect how much the agent can retrieve during the agentic loop — only the final answer length.
### Python
```python
response = client.reflect(
bank_id="my-bank",
query="What do you think about remote work?",
budget="mid",
context="We're considering a hybrid work policy"
)
```
### Node.js
```javascript
const response = await client.reflect('my-bank', 'What do you think about remote work?', {
budget: 'mid',
context: "We're considering a hybrid work policy"
});
```
## Disposition Influence
The bank's disposition affects reflect responses:
| Trait | Low (1) | High (5) |
|-------|---------|----------|
| **Skepticism** | Trusting, accepts claims | Questions and doubts claims |
| **Literalism** | Flexible interpretation | Exact, literal interpretation |
| **Empathy** | Detached, fact-focused | Considers emotional context |
### Python
```python
# Create a bank with specific disposition
client.create_bank(
bank_id="cautious-advisor",
name="Cautious Advisor",
mission="I am a risk-aware financial advisor",
disposition={
"skepticism": 5, # Very skeptical of claims
"literalism": 4, # Focuses on exact requirements
"empathy": 2 # Prioritizes facts over feelings
}
)
# Reflect responses will reflect this disposition
response = client.reflect(
bank_id="cautious-advisor",
query="Should I invest in crypto?"
)
# Response will likely emphasize risks and caution
```
### Node.js
```javascript
// Create a bank with specific disposition
await client.createBank('cautious-advisor', {
name: 'Cautious Advisor',
background: 'I am a risk-aware financial advisor',
disposition: {
skepticism: 5,
literalism: 4,
empathy: 2
}
});
// Reflect responses will reflect this disposition
const advisorResponse = await client.reflect('cautious-advisor', 'Should I invest in crypto?');
```
## Citations
The response includes a `based_on` field that shows which sources were used:
- `based_on.memories` — Memory facts (world, experience) that were retrieved and cited
- `based_on.mental_models` — User-curated mental models that were used
- `based_on.directives` — Directives that were enforced
**Important:** Only IDs that were actually retrieved during the agent loop can be cited. The agent validates citations to prevent hallucinated references.
This enables:
- **Transparency** — users see exactly which sources informed the answer
- **Verification** — check if the response is grounded in actual memories
- **Debugging** — use `trace=True` for detailed tool call logs
## Structured Output
For applications that need to process responses programmatically, you can request structured output by providing a JSON Schema via `response_schema`. When provided, the response includes a `structured_output` field with the LLM response parsed according to the schema. The `text` field will be empty since only a single LLM call is made for efficiency.
The easiest way to define a schema is using **Pydantic models**:
### Python
```python
from pydantic import BaseModel
# Define your response structure with Pydantic
class HiringRecommendation(BaseModel):
recommendation: str
confidence: str # "low", "medium", "high"
key_factors: list[str]
risks: list[str] = []
response = client.reflect(
bank_id="hiring-team",
query="Should we hire Alice for the ML team lead position?",
response_schema=HiringRecommendation.model_json_schema(),
)
# Parse structured output into Pydantic model
result = HiringRecommendation.model_validate(response.structured_output)
print(f"Recommendation: {result.recommendation}")
print(f"Confidence: {result.confidence}")
print(f"Key factors: {result.key_factors}")
```
### Node.js
```javascript
// Define JSON schema directly
const responseSchema = {
type: 'object',
properties: {
recommendation: { type: 'string' },
confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
key_factors: { type: 'array', items: { type: 'string' } },
risks: { type: 'array', items: { type: 'string' } },
},
required: ['recommendation', 'confidence', 'key_factors'],
};
const structuredResponse = await client.reflect('my-bank', 'What do you know about Alice and her career?', {
responseSchema: responseSchema,
});
// Structured output (if returned)
if (structuredResponse.structuredOutput) {
console.log('Recommendation:', structuredResponse.structuredOutput.recommendation || 'N/A');
console.log('Key factors:', structuredResponse.structuredOutput.key_factors || []);
}
```
### CLI
```bash
# First, create a JSON schema file schema.json:
cat > schema.json << 'EOF'
{
"type": "object",
"properties": {
"recommendation": {"type": "string"},
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
"key_factors": {"type": "array", "items": {"type": "string"}}
},
"required": ["recommendation", "confidence", "key_factors"]
}
EOF
# Then use the --schema flag:
hindsight memory reflect hiring-team \
"Should we hire Alice for the ML team lead position?" \
--schema schema.json
# Cleanup the temporary schema file
rm -f schema.json
```
| Use Case | Why Structured Output Helps |
|----------|----------------------------|
| **Decision pipelines** | Parse recommendations into workflow systems |
| **Dashboards** | Extract confidence scores, risk factors for visualization |
| **Multi-agent systems** | Pass structured data between agents |
| **Auditing** | Log structured decisions with clear reasoning |
**Tips:**
- Use Pydantic's `model_json_schema()` for type-safe schema generation
- Use `model_validate()` to parse the response back into your Pydantic model
- Keep schemas focused — extract only what you need
- Use `Optional` fields for data that may not always be available
## Filter by Tags
Like [recall](./recall#filter-by-tags), reflect supports tag filtering to scope which memories are considered during reasoning. This is essential for multi-user scenarios where reflection should only consider memories relevant to a specific user.
### Python
```python
# Filter reflection to only consider memories for a specific user
response = client.reflect(
bank_id="my-bank",
query="What does this user think about our product?",
tags=["user:alice"],
tags_match="any_strict" # Only use memories tagged for this user
)
```
The `tags_match` parameter works the same as in recall:
| Mode | Behavior |
|------|----------|
| `any` | OR matching, includes untagged memories |
| `all` | AND matching, includes untagged memories |
| `any_strict` | OR matching, excludes untagged memories |
| `all_strict` | AND matching, excludes untagged memories |
See [Retain API](./retain#tagging-memories) for how to tag memories and [Recall API](./recall#filter-by-tags) for more details on tag matching modes.
@@ -0,0 +1,254 @@
# Ingest Data
Store documents, conversations, and raw content into Hindsight to automatically extract and create memories.
When you **retain** content, Hindsight doesn't just store the raw text—it intelligently analyzes the content to extract meaningful facts, identify entities, and build a connected knowledge graph. This process transforms unstructured information into structured, queryable memories.
{/* Import raw source files */}
:::info How Retain Works
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
> **💡 Prerequisites**
>
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
## Store a Single Memory
### Python
```python
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
```
### Node.js
```javascript
await client.retain('my-bank', 'Alice works at Google as a software engineer');
```
### CLI
```bash
hindsight memory retain my-bank "Alice works at Google as a software engineer"
```
## The Importance of Context
The `context` parameter is crucial for guiding how Hindsight extracts memories from your content. Think of it as providing a lens through which the system interprets the information.
**Why context matters:**
- **Steers memory extraction**: Context tells the memory bank what type of information to focus on and how to interpret ambiguous content
- **Improves relevance**: Memories extracted with proper context are more accurately categorized and easier to retrieve
- **Disambiguates meaning**: The same sentence can have different implications depending on context (e.g., "the project was terminated" means different things in a career vs. product context)
## Store with Context and Date
Always provide context and event dates for optimal memory extraction:
### Python
```python
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2024-03-15T10:00:00Z"
)
```
### Node.js
```javascript
await client.retain('my-bank', 'Alice got promoted to senior engineer', {
context: 'career update',
timestamp: '2024-03-15T10:00:00Z'
});
```
### CLI
```bash
hindsight memory retain my-bank "Alice got promoted" \
--context "career update"
```
The `timestamp` defaults to the current time if not specified. Providing explicit timestamps enables temporal queries like "What happened last spring?"
### Response Fields
The retain response includes:
| Field | Type | Description |
|-------|------|-------------|
| `success` | bool | Whether the operation succeeded |
| `bank_id` | string | The memory bank ID |
| `items_count` | int | Number of items processed |
| `async` | bool | Whether processed asynchronously |
| `usage` | TokenUsage | Token usage metrics for LLM calls (synchronous only) |
The `usage` field contains token metrics for cost tracking:
- `input_tokens`: Tokens consumed by prompts
- `output_tokens`: Tokens generated by the LLM
- `total_tokens`: Sum of input and output tokens
Note: `usage` is only present for synchronous operations. Async operations (`async: true`) do not return usage metrics.
## Batch Ingestion
Store multiple items in a single request. **Batch ingestion is the recommended approach** as it significantly improves performance by reducing network overhead and allowing Hindsight to optimize the memory extraction process across related content.
### Python
```python
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"},
{"content": "Alice and Bob are friends", "context": "relationship"}
],
document_id="conversation_001"
)
```
### Node.js
```javascript
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' });
```
The `document_id` groups related memories for later management.
## Store from Files
### CLI
```bash
# Single file
hindsight memory retain-files my-bank document.txt
# Directory (recursive by default)
hindsight memory retain-files my-bank ./documents/
```
## Async Ingestion
For large batches, use async ingestion to avoid blocking:
### Python
```python
# Start async ingestion (returns immediately)
result = client.retain_batch(
bank_id="my-bank",
items=[
{"content": "Large batch item 1"},
{"content": "Large batch item 2"},
],
document_id="large-doc",
retain_async=True
)
# Check if it was processed asynchronously
print(result.var_async) # True
```
### Node.js
```javascript
// Start async ingestion (returns immediately)
await client.retainBatch('my-bank', [
{ content: 'Large batch item 1' },
{ content: 'Large batch item 2' },
], {
documentId: 'large-doc',
async: true
});
```
## Tagging Memories
Tags enable **visibility scoping**—useful when one memory bank serves multiple users but each should only see relevant memories. For example, an agent that chats with multiple users can tag memories by user ID and filter during recall.
### Tag Individual Items
### Python
```python
# Tag individual items for visibility scoping
client.retain_batch(
bank_id="my-bank",
items=[
{
"content": "User Alice said she loves the new dashboard",
"tags": ["user:alice", "feedback"]
},
{
"content": "User Bob reported a bug in the search feature",
"tags": ["user:bob", "bug-report"]
}
],
document_id="user_feedback_001"
)
```
### Apply Tags to All Items in a Batch
Use `document_tags` to apply the same tags to all items in a request:
### Python
```python
# Apply tags to all items in a batch
client.retain_batch(
bank_id="my-bank",
items=[
{"content": "Alice mentioned she prefers dark mode"},
{"content": "Bob asked about keyboard shortcuts"}
],
document_id="support_session_123",
document_tags=["session:123", "support"] # Applied to all items
)
```
When both `document_tags` and item-level `tags` are provided, they are merged together.
### Tag Naming Conventions
Use consistent naming patterns for tags:
| Pattern | Example | Use Case |
|---------|---------|----------|
| `user:<id>` | `user:alice` | Multi-user agent filtering |
| `session:<id>` | `session:123` | Session-based scoping |
| `room:<id>` | `room:general` | Chat room isolation |
| `topic:<name>` | `topic:feedback` | Topic categorization |
### Listing Tags
Use the list tags API to discover existing tags, useful for UI autocomplete or wildcard expansion:
### Python
```python
# List all tags in a bank
response = requests.get(f"{HINDSIGHT_URL}/v1/default/banks/my-bank/tags")
tags = response.json()
for tag in tags["items"]:
print(f"{tag['tag']}: {tag['count']} memories")
# Search with wildcards (* matches any characters)
response = requests.get(f"{HINDSIGHT_URL}/v1/default/banks/my-bank/tags", params={"q": "user:*"})
user_tags = response.json()
response = requests.get(f"{HINDSIGHT_URL}/v1/default/banks/my-bank/tags", params={"q": "*-admin"})
admin_tags = response.json()
```
See [Recall API](./recall#filter-by-tags) for filtering memories by tags during retrieval.
@@ -0,0 +1,595 @@
# Configuration
Complete reference for configuring Hindsight services through environment variables.
Hindsight has two services, each with its own configuration prefix:
| Service | Prefix | Description |
|---------|--------|-------------|
| **API Service** | `HINDSIGHT_API_*` | Core memory engine |
| **Control Plane** | `HINDSIGHT_CP_*` | Web UI |
---
## API Service
The API service handles all memory operations (retain, recall, reflect).
### Database
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
| `HINDSIGHT_API_DATABASE_SCHEMA` | PostgreSQL schema name for tables | `public` |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` |
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
The `DATABASE_SCHEMA` setting allows you to use a custom PostgreSQL schema instead of the default `public` schema. This is useful for:
- Multi-database setups where you want Hindsight tables in a dedicated schema
- Hosting platforms (e.g., Supabase) where `public` schema is reserved or shared
- Organizational preferences for schema naming conventions
```bash
# Example: Using a custom schema
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/dbname
export HINDSIGHT_API_DATABASE_SCHEMA=hindsight
```
Migrations will automatically create the schema if it doesn't exist and create all tables in the configured schema.
### Database Connection Pool
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DB_POOL_MIN_SIZE` | Minimum connections in the pool | `5` |
| `HINDSIGHT_API_DB_POOL_MAX_SIZE` | Maximum connections in the pool | `100` |
| `HINDSIGHT_API_DB_COMMAND_TIMEOUT` | PostgreSQL command timeout in seconds | `60` |
| `HINDSIGHT_API_DB_ACQUIRE_TIMEOUT` | Connection acquisition timeout in seconds | `30` |
For high-concurrency workloads, increase `DB_POOL_MAX_SIZE`. Each concurrent recall/think operation can use 2-4 connections.
To run migrations manually (e.g., before starting the API), use the admin CLI:
```bash
hindsight-admin run-db-migration
# Or for a specific schema:
hindsight-admin run-db-migration --schema tenant_acme
```
### LLM Provider
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-codex`, `claude-code`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `vertexai` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-5-mini` |
| `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default |
| `HINDSIGHT_API_LLM_MAX_CONCURRENT` | Max concurrent LLM requests | `32` |
| `HINDSIGHT_API_LLM_MAX_RETRIES` | Max retry attempts for LLM API calls | `10` |
| `HINDSIGHT_API_LLM_INITIAL_BACKOFF` | Initial retry backoff in seconds (exponential backoff) | `1.0` |
| `HINDSIGHT_API_LLM_MAX_BACKOFF` | Max retry backoff cap in seconds | `60.0` |
| `HINDSIGHT_API_LLM_TIMEOUT` | LLM request timeout in seconds | `120` |
| `HINDSIGHT_API_LLM_GROQ_SERVICE_TIER` | Groq service tier: `on_demand`, `flex`, `auto` | `auto` |
**Provider Examples**
```bash
# Groq (recommended for fast inference)
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=openai/gpt-oss-20b
# For free tier users: override to on_demand if you get service_tier errors
# export HINDSIGHT_API_LLM_GROQ_SERVICE_TIER=on_demand
# OpenAI
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o
# Gemini
export HINDSIGHT_API_LLM_PROVIDER=gemini
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
# Anthropic
export HINDSIGHT_API_LLM_PROVIDER=anthropic
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Vertex AI (Google Cloud - uses native genai SDK)
export HINDSIGHT_API_LLM_PROVIDER=vertexai
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash-001
export HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-gcp-project-id
export HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# Optional: use ADC (gcloud auth application-default login) or provide service account key:
# export HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json
# Ollama (local, no API key)
export HINDSIGHT_API_LLM_PROVIDER=ollama
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
export HINDSIGHT_API_LLM_MODEL=llama3
# LM Studio (local, no API key)
export HINDSIGHT_API_LLM_PROVIDER=lmstudio
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
export HINDSIGHT_API_LLM_MODEL=your-local-model
# OpenAI-compatible endpoint
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_BASE_URL=https://your-endpoint.com/v1
export HINDSIGHT_API_LLM_API_KEY=your-api-key
export HINDSIGHT_API_LLM_MODEL=your-model-name
# OpenAI Codex (ChatGPT Plus/Pro subscription - uses OAuth, no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
export HINDSIGHT_API_LLM_MODEL=gpt-5.2-codex
# No API key needed - uses OAuth tokens from ~/.codex/auth.json
# Claude Code (Claude Pro/Max subscription - uses OAuth, no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=claude-code
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-5-20250929
# No API key needed - uses claude auth login credentials
```
:::tip OpenAI Codex & Claude Code Setup
For detailed setup instructions for **OpenAI Codex** (ChatGPT Plus/Pro) and **Claude Code** (Claude Pro/Max), see the [Models documentation](./models#openai-codex-setup-chatgpt-pluspro).
:::
#### Vertex AI Setup
Google Cloud's Vertex AI provides access to Gemini models via the native Google GenAI SDK. Hindsight supports two authentication methods:
**Prerequisites:**
- GCP project with Vertex AI API enabled
- IAM role `roles/aiplatform.user` for your credentials
**Environment Variables:**
| Variable | Description | Required |
|----------|-------------|----------|
| `HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID` | Your GCP project ID | Yes |
| `HINDSIGHT_API_LLM_VERTEXAI_REGION` | GCP region (e.g., `us-central1`) | No (default: `us-central1`) |
| `HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY` | Path to service account JSON key file | No (uses ADC if not set) |
**Authentication Methods:**
1. **Application Default Credentials (ADC)** - Recommended for development
```bash
# Setup ADC
gcloud auth application-default login
# Configure Hindsight
export HINDSIGHT_API_LLM_PROVIDER=vertexai
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash-001
export HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-project-id
```
2. **Service Account Key** - Recommended for production
```bash
# Create service account and download key
gcloud iam service-accounts create hindsight-api
gcloud projects add-iam-policy-binding your-project-id \
--member="serviceAccount:hindsight-api@your-project-id.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
gcloud iam service-accounts keys create key.json \
--iam-account=hindsight-api@your-project-id.iam.gserviceaccount.com
# Configure Hindsight
export HINDSIGHT_API_LLM_PROVIDER=vertexai
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash-001
export HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-project-id
export HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/key.json
```
**Notes:**
- Model names can optionally include the `google/` prefix (e.g., `google/gemini-2.0-flash-001`) - it will be stripped automatically
- The native SDK handles token refresh automatically
- Uses service account credentials if provided, otherwise falls back to ADC
### Per-Operation LLM Configuration
Different memory operations have different requirements. **Retain** (fact extraction) benefits from models with strong structured output capabilities, while **Reflect** (reasoning/response generation) can use lighter, faster models. Configure separate LLM models for each operation to optimize for cost and performance.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_RETAIN_LLM_PROVIDER` | LLM provider for retain operations | Falls back to `HINDSIGHT_API_LLM_PROVIDER` |
| `HINDSIGHT_API_RETAIN_LLM_API_KEY` | API key for retain LLM | Falls back to `HINDSIGHT_API_LLM_API_KEY` |
| `HINDSIGHT_API_RETAIN_LLM_MODEL` | Model for retain operations | Falls back to `HINDSIGHT_API_LLM_MODEL` |
| `HINDSIGHT_API_RETAIN_LLM_BASE_URL` | Base URL for retain LLM | Falls back to `HINDSIGHT_API_LLM_BASE_URL` |
| `HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT` | Max concurrent requests for retain | Falls back to `HINDSIGHT_API_LLM_MAX_CONCURRENT` |
| `HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES` | Max retries for retain | Falls back to `HINDSIGHT_API_LLM_MAX_RETRIES` |
| `HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF` | Initial backoff for retain retries (seconds) | Falls back to `HINDSIGHT_API_LLM_INITIAL_BACKOFF` |
| `HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF` | Max backoff cap for retain retries (seconds) | Falls back to `HINDSIGHT_API_LLM_MAX_BACKOFF` |
| `HINDSIGHT_API_RETAIN_LLM_TIMEOUT` | Timeout for retain requests (seconds) | Falls back to `HINDSIGHT_API_LLM_TIMEOUT` |
| `HINDSIGHT_API_REFLECT_LLM_PROVIDER` | LLM provider for reflect operations | Falls back to `HINDSIGHT_API_LLM_PROVIDER` |
| `HINDSIGHT_API_REFLECT_LLM_API_KEY` | API key for reflect LLM | Falls back to `HINDSIGHT_API_LLM_API_KEY` |
| `HINDSIGHT_API_REFLECT_LLM_MODEL` | Model for reflect operations | Falls back to `HINDSIGHT_API_LLM_MODEL` |
| `HINDSIGHT_API_REFLECT_LLM_BASE_URL` | Base URL for reflect LLM | Falls back to `HINDSIGHT_API_LLM_BASE_URL` |
| `HINDSIGHT_API_REFLECT_LLM_MAX_CONCURRENT` | Max concurrent requests for reflect | Falls back to `HINDSIGHT_API_LLM_MAX_CONCURRENT` |
| `HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES` | Max retries for reflect | Falls back to `HINDSIGHT_API_LLM_MAX_RETRIES` |
| `HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF` | Initial backoff for reflect retries (seconds) | Falls back to `HINDSIGHT_API_LLM_INITIAL_BACKOFF` |
| `HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF` | Max backoff cap for reflect retries (seconds) | Falls back to `HINDSIGHT_API_LLM_MAX_BACKOFF` |
| `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` | Timeout for reflect requests (seconds) | Falls back to `HINDSIGHT_API_LLM_TIMEOUT` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER` | LLM provider for observation consolidation | Falls back to `HINDSIGHT_API_LLM_PROVIDER` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY` | API key for consolidation LLM | Falls back to `HINDSIGHT_API_LLM_API_KEY` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_MODEL` | Model for consolidation operations | Falls back to `HINDSIGHT_API_LLM_MODEL` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL` | Base URL for consolidation LLM | Falls back to `HINDSIGHT_API_LLM_BASE_URL` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT` | Max concurrent requests for consolidation | Falls back to `HINDSIGHT_API_LLM_MAX_CONCURRENT` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES` | Max retries for consolidation | Falls back to `HINDSIGHT_API_LLM_MAX_RETRIES` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF` | Initial backoff for consolidation retries (seconds) | Falls back to `HINDSIGHT_API_LLM_INITIAL_BACKOFF` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF` | Max backoff cap for consolidation retries (seconds) | Falls back to `HINDSIGHT_API_LLM_MAX_BACKOFF` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT` | Timeout for consolidation requests (seconds) | Falls back to `HINDSIGHT_API_LLM_TIMEOUT` |
:::tip When to Use Per-Operation Config
- **Retain**: Use models with strong structured output (e.g., GPT-4o, Claude) for accurate fact extraction
- **Reflect**: Use faster/cheaper models (e.g., GPT-4o-mini, Groq) for reasoning and response generation
- **Recall**: Does not use LLM (pure retrieval), so no configuration needed
:::
**Example: Separate Models for Retain and Reflect**
```bash
# Default LLM (used as fallback)
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o
# Use GPT-4o for retain (strong structured output)
export HINDSIGHT_API_RETAIN_LLM_MODEL=gpt-4o
# Use faster/cheaper model for reflect
export HINDSIGHT_API_REFLECT_LLM_PROVIDER=groq
export HINDSIGHT_API_REFLECT_LLM_API_KEY=gsk_xxxxxxxxxxxx
export HINDSIGHT_API_REFLECT_LLM_MODEL=llama-3.3-70b-versatile
```
**Example: Tuning Retry Behavior for Rate-Limited APIs**
```bash
# For Anthropic with tight rate limits (10k output tokens/minute)
export HINDSIGHT_API_LLM_PROVIDER=anthropic
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Reduce concurrent requests for retain to avoid rate limits
export HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT=3
# Fail faster with fewer retries
export HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES=3
# Or increase backoff times to wait out rate limit windows
export HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF=2.0 # Start at 2s instead of 1s
export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1min
```
### Embeddings
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `cohere`, or `litellm` | `local` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` |
| `HINDSIGHT_API_EMBEDDINGS_TEI_URL` | TEI server URL | - |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY` | OpenAI API key (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL` | OpenAI embedding model | `text-embedding-3-small` |
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL` | Custom base URL for OpenAI-compatible API (e.g., Azure OpenAI) | - |
| `HINDSIGHT_API_COHERE_API_KEY` | Cohere API key (shared for embeddings and reranker) | - |
| `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` |
| `HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
| `HINDSIGHT_API_LITELLM_API_BASE` | LiteLLM proxy base URL (shared for embeddings and reranker) | `http://localhost:4000` |
| `HINDSIGHT_API_LITELLM_API_KEY` | LiteLLM proxy API key (optional, depends on proxy config) | - |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL` | LiteLLM embedding model (use provider prefix, e.g., `cohere/embed-english-v3.0`) | `text-embedding-3-small` |
```bash
# Local (default) - uses SentenceTransformers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# OpenAI - cloud-based embeddings
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxxxxxxxxxx # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small # 1536 dimensions
# Azure OpenAI - embeddings via Azure endpoint
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=your-azure-api-key
export HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
export HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment
# TEI - HuggingFace Text Embeddings Inference (recommended for production)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei
export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# Cohere - cloud-based embeddings
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 # 1024 dimensions
# Azure-hosted Cohere - embeddings via custom endpoint
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0
export HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
# LiteLLM proxy - unified gateway for multiple providers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL=text-embedding-3-small # or cohere/embed-english-v3.0
```
#### Embedding Dimensions
Hindsight automatically detects the embedding dimension from the model at startup and adjusts the database schema accordingly. The default model (`BAAI/bge-small-en-v1.5`) produces 384-dimensional vectors, while OpenAI models produce 1536 or 3072 dimensions.
:::warning Dimension Changes
Once memories are stored, you cannot change the embedding dimension without losing data. If you need to switch to a model with different dimensions:
1. **Empty database**: The schema is adjusted automatically on startup
2. **Existing data**: Either delete all memories first, or use a model with matching dimensions
Supported OpenAI embedding dimensions:
- `text-embedding-3-small`: 1536 dimensions
- `text-embedding-3-large`: 3072 dimensions
- `text-embedding-ada-002`: 1536 dimensions (legacy)
:::
### Reranker
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, `cohere`, `flashrank`, `litellm`, or `rrf` | `local` |
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
| `HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT` | Max concurrent local reranking (prevents CPU thrashing under load) | `4` |
| `HINDSIGHT_API_RERANKER_TEI_URL` | TEI server URL | - |
| `HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE` | Batch size for TEI reranking | `128` |
| `HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT` | Max concurrent TEI reranking requests | `8` |
| `HINDSIGHT_API_RERANKER_COHERE_MODEL` | Cohere rerank model | `rerank-english-v3.0` |
| `HINDSIGHT_API_RERANKER_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
| `HINDSIGHT_API_RERANKER_LITELLM_MODEL` | LiteLLM rerank model (use provider prefix, e.g., `cohere/rerank-english-v3.0`) | `cohere/rerank-english-v3.0` |
| `HINDSIGHT_API_RERANKER_FLASHRANK_MODEL` | FlashRank model for fast CPU-based reranking | `ms-marco-MiniLM-L-12-v2` |
| `HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR` | Cache directory for FlashRank models | System default |
```bash
# Local (default) - uses SentenceTransformers CrossEncoder
export HINDSIGHT_API_RERANKER_PROVIDER=local
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# TEI - for high-performance inference
export HINDSIGHT_API_RERANKER_PROVIDER=tei
export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Cohere - cloud-based reranking
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-api-key # shared with embeddings
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
# Azure-hosted Cohere - reranking via custom endpoint
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
export HINDSIGHT_API_RERANKER_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
# LiteLLM proxy - unified gateway for multiple reranking providers
export HINDSIGHT_API_RERANKER_PROVIDER=litellm
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_RERANKER_LITELLM_MODEL=cohere/rerank-english-v3.0 # or voyage/rerank-2, together_ai/...
```
LiteLLM supports multiple reranking providers via the `/rerank` endpoint:
- Cohere (`cohere/rerank-english-v3.0`, `cohere/rerank-multilingual-v3.0`)
- Together AI (`together_ai/...`)
- Voyage AI (`voyage/rerank-2`)
- Jina AI (`jina_ai/...`)
- AWS Bedrock (`bedrock/...`)
### Authentication
By default, Hindsight runs without authentication. For production deployments, enable API key authentication using the built-in tenant extension:
```bash
# Enable the built-in API key authentication
export HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
export HINDSIGHT_API_TENANT_API_KEY=your-secret-api-key
```
When enabled, all requests must include the API key in the `Authorization` header:
```bash
curl -H "Authorization: Bearer your-secret-api-key" \
http://localhost:8888/v1/default/banks
```
Requests without a valid API key receive a `401 Unauthorized` response.
:::tip Custom Authentication
For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a custom `TenantExtension`. See the [Extensions documentation](./extensions.md) for details.
:::
### Server
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_HOST` | Bind address | `0.0.0.0` |
| `HINDSIGHT_API_PORT` | Server port | `8888` |
| `HINDSIGHT_API_WORKERS` | Number of uvicorn worker processes | `1` |
| `HINDSIGHT_API_LOG_LEVEL` | Log level: `debug`, `info`, `warning`, `error` | `info` |
| `HINDSIGHT_API_LOG_FORMAT` | Log format: `text` or `json` (structured logging for cloud platforms) | `text` |
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
### Retrieval
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_GRAPH_RETRIEVER` | Graph retrieval algorithm: `link_expansion`, `mpfp`, or `bfs` | `link_expansion` |
| `HINDSIGHT_API_RECALL_MAX_CONCURRENT` | Max concurrent recall operations per worker (backpressure) | `32` |
| `HINDSIGHT_API_RECALL_CONNECTION_BUDGET` | Max concurrent DB connections per recall operation | `4` |
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
| `HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS` | Fan-out limit per node in MPFP graph traversal | `20` |
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
#### Graph Retrieval Algorithms
- **`link_expansion`** (default): Fast, simple graph expansion from semantic seeds via entity co-occurrence and causal links. Target latency under 100ms. Recommended for most use cases.
- **`mpfp`**: Multi-Path Fact Propagation - iterative graph traversal with activation spreading. More thorough but slower.
- **`bfs`**: Breadth-first search from seed facts. Simple but less effective for large graphs.
### Retain
Controls the retain (memory ingestion) pipeline.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
| `HINDSIGHT_API_RETAIN_CHUNK_SIZE` | Max characters per chunk for fact extraction. Larger chunks extract fewer LLM calls but may lose context. | `3000` |
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise`, `verbose`, or `custom` | `concise` |
| `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` | Custom extraction guidelines (only used when mode is `custom`) | - |
| `HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS` | Extract causal relationships between facts | `true` |
#### Extraction Modes
The extraction mode controls how aggressively facts are extracted from content:
- **`concise`** (default): Selective extraction that focuses on significant, long-term valuable facts. Filters out greetings, filler, and trivial information. Produces fewer but higher-quality facts with better performance.
- **`verbose`**: Detailed extraction that captures every piece of information with maximum verbosity. Produces more facts with extensive detail but slower performance and higher token usage.
- **`custom`**: Inject your own extraction guidelines while keeping the structural parts of the prompt (output format, coreference resolution, temporal handling, etc.) intact. Useful for A/B testing different extraction strategies or domain-specific customization.
**Example: Custom Extraction Mode**
```bash
# Set mode to custom
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
# Define custom guidelines (multi-line is fine)
export HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS="ONLY extract facts that are:
✅ Technical decisions and their rationale
✅ Architecture patterns and design choices
✅ Performance metrics and benchmarks
✅ Code reviews and feedback
DO NOT extract:
❌ Generic greetings or pleasantries
❌ Process chatter (\"let me check\", \"one moment\")
❌ Repeated information already captured
CONSOLIDATE related technical discussions into ONE fact when possible.
Ask yourself: 'Would this technical context be useful in 6 months?' If no, skip it."
```
### Observations (Experimental)
Observations are consolidated knowledge synthesized from facts.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
### Reflect
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` | Max tool call iterations before forcing a response | `10` |
### MCP Server
Configuration for MCP server endpoints.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
| `HINDSIGHT_API_MCP_AUTH_TOKEN` | Bearer token for MCP authentication (optional) | - |
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | Memory bank ID for local MCP | `mcp` |
| `HINDSIGHT_API_MCP_INSTRUCTIONS` | Additional instructions appended to retain/recall tool descriptions | - |
**MCP Authentication:**
By default, the MCP endpoint is open. For production deployments, set `HINDSIGHT_API_MCP_AUTH_TOKEN` to require Bearer token authentication:
```bash
export HINDSIGHT_API_MCP_AUTH_TOKEN=your-secret-token
```
Clients must then include the token in the `Authorization` header. See [MCP Server documentation](./mcp-server.md#authentication) for details.
**Local MCP instructions:**
```bash
# Example: instruct MCP to also store assistant actions
export HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls and decisions made."
```
### Distributed Workers
Configuration for background task processing. By default, the API processes tasks internally. For high-throughput deployments, run dedicated workers. See [Services - Worker Service](./services#worker-service) for details.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_WORKER_ENABLED` | Enable internal worker in API process | `true` |
| `HINDSIGHT_API_WORKER_ID` | Unique worker identifier | hostname |
| `HINDSIGHT_API_WORKER_POLL_INTERVAL_MS` | Database polling interval in milliseconds | `500` |
| `HINDSIGHT_API_WORKER_MAX_RETRIES` | Max retries before marking task failed | `3` |
| `HINDSIGHT_API_WORKER_HTTP_PORT` | HTTP port for worker metrics/health (worker CLI only) | `8889` |
| `HINDSIGHT_API_WORKER_MAX_SLOTS` | Maximum concurrent tasks per worker | `10` |
| `HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLOTS` | Maximum concurrent consolidation tasks per worker | `2` |
### Performance Optimization
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_SKIP_LLM_VERIFICATION` | Skip LLM connection check on startup | `false` |
| `HINDSIGHT_API_LAZY_RERANKER` | Lazy-load reranker model (faster startup) | `false` |
### Programmatic Configuration
You can also configure the API programmatically using `MemoryEngine.from_env()`:
```python
from hindsight_api import MemoryEngine
memory = MemoryEngine.from_env()
await memory.initialize()
```
---
## Control Plane
The Control Plane is the web UI for managing memory banks.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_CP_DATAPLANE_API_URL` | URL of the API service | `http://localhost:8888` |
```bash
# Point Control Plane to a remote API service
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
```
---
## Example .env File
```bash
# API Service
HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:hindsight_dev@localhost:5432/hindsight
# HINDSIGHT_API_DATABASE_SCHEMA=public # optional, defaults to 'public'
HINDSIGHT_API_LLM_PROVIDER=groq
HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
# Authentication (optional, recommended for production)
# HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
# HINDSIGHT_API_TENANT_API_KEY=your-secret-api-key
# Control Plane
HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
```
---
For configuration issues not covered here, please [open an issue](https://github.com/vectorize-io/hindsight/issues) on GitHub.
@@ -0,0 +1,149 @@
---
sidebar_position: 7
---
# Development Guide
Guide to setting up a local development environment for contributing to Hindsight.
## Prerequisites
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) - Fast Python package manager
- Docker and Docker Compose
- An LLM API key (OpenAI, Groq, or Ollama)
## Local Development Setup
### 1. Clone the Repository
```bash
git clone https://github.com/vectorize-io/hindsight.git
cd hindsight
```
### 2. Install Dependencies
```bash
uv sync
```
### 3. Start PostgreSQL
Start only the database via Docker:
```bash
cd docker && docker-compose up -d postgres
```
### 4. Configure Environment
```bash
cp .env.example .env
```
Edit `.env` with your LLM API key:
```bash
# Database (connects to Docker postgres)
HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:hindsight_dev@localhost:5432/hindsight
# LLM Provider (choose one)
HINDSIGHT_API_LLM_PROVIDER=groq
HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
HINDSIGHT_API_LLM_MODEL=llama-3.1-70b-versatile
```
### 5. Start the API Server
```bash
./scripts/start-server.sh --env local
```
The server will be available at http://localhost:8888.
## Running Tests
```bash
# Run all tests
uv run pytest
# Run specific test file
uv run pytest tests/test_retrieval.py
# Run with verbose output
uv run pytest -v
```
## Code Generation
### Regenerate API Clients
When you modify the OpenAPI spec, regenerate the clients:
```bash
./scripts/generate-clients.sh
```
This generates:
- Python client in `hindsight-clients/python/`
- TypeScript client in `hindsight-clients/typescript/`
### Export OpenAPI Schema
```bash
./scripts/export-openapi.sh
```
## Project Structure
```
hindsight/
├── hindsight-api/ # Main API server
│ ├── hindsight_api/
│ │ ├── api/ # HTTP endpoints
│ │ ├── engine/ # Memory engine, retrieval, reasoning
│ │ └── web/ # Server entry point
│ └── tests/
├── hindsight-clients/ # Generated SDK clients
│ ├── python/
│ └── typescript/
├── hindsight-control-plane/ # Admin UI (Next.js)
├── docker/ # Docker Compose setup
└── scripts/ # Development scripts
```
## Contributing
1. Create a feature branch from `main`
2. Make your changes
3. Run tests: `uv run pytest`
4. Submit a pull request
## Troubleshooting
### Database Connection Issues
Ensure PostgreSQL is running:
```bash
docker-compose ps
```
Check database connectivity:
```bash
psql postgresql://hindsight:hindsight_dev@localhost:5432/hindsight
```
### ML Model Download
On first run, Hindsight downloads embedding and reranking models. This may take a few minutes. Models are cached in `~/.cache/huggingface/`.
### Port Conflicts
If port 8888 is in use:
```bash
HINDSIGHT_API_PORT=8889 ./scripts/start-server.sh --env local
```
@@ -0,0 +1,269 @@
# Extensions
Extensions allow you to customize and extend Hindsight behavior without modifying core code. They enable multi-tenancy, custom authentication, additional HTTP endpoints, and operation hooks.
---
## Available Extensions
### TenantExtension
Handles multi-tenancy and API key authentication. Validates incoming requests and determines which PostgreSQL schema to use for database operations, enabling tenant isolation at the database level.
**Built-in: ApiKeyTenantExtension**
A simple implementation that validates API keys against an environment variable and uses the `public` schema for all authenticated requests.
```bash
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
```
**Built-in: SupabaseTenantExtension**
Validates [Supabase](https://supabase.com) JWTs and provides multi-tenant memory isolation. Each authenticated user gets their own PostgreSQL schema (`{prefix}_{user_id}`), ensuring complete data separation. Performs local JWT verification using JWKS for optimal performance (no network call per request).
```bash
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
# Optional - only needed for legacy HS256 projects or health check
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
```
See the [source code](https://github.com/vectorize-io/hindsight/blob/main/hindsight-api/hindsight_api/extensions/builtin/supabase_tenant.py) for complete configuration options and implementation details.
For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT-based auth), implement a custom `TenantExtension`.
---
### HttpExtension
Adds custom HTTP endpoints under the `/ext/` path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine.
**No built-in implementation** - implement your own to add custom endpoints.
```bash
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
```
---
### OperationValidatorExtension
Hooks into retain/recall/reflect operations for validation and monitoring. Use cases include:
- Rate limiting and quota enforcement
- Permission checks and content filtering
- Audit logging and usage tracking
- Custom metrics collection
**No built-in implementation** - implement your own based on your requirements.
```bash
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
```
---
### MCPExtension
Registers additional MCP (Model Context Protocol) tools on the Hindsight MCP server. Enables external packages to add custom tools without modifying core code.
**No built-in implementation** - implement your own to add custom MCP tools.
```bash
HINDSIGHT_API_MCP_EXTENSION=mypackage.mcp:MyMCPExtension
```
---
## Writing Custom Extensions
### Extension Basics
Extensions are Python classes loaded via environment variables:
```bash
HINDSIGHT_API_<TYPE>_EXTENSION=mypackage.module:MyExtensionClass
```
Configuration is passed via prefixed environment variables:
```bash
HINDSIGHT_API_<TYPE>_SOME_CONFIG=value
# Extension receives: {"some_config": "value"}
```
All extensions support lifecycle hooks:
- `on_startup()` - Called when the application starts
- `on_shutdown()` - Called when the application shuts down
Extensions have access to an `ExtensionContext` that provides:
- `run_migration(schema)` - Run database migrations for a schema
- `get_memory_engine()` - Get the MemoryEngine interface
### Example: Custom TenantExtension with JWT
```python
import jwt
from hindsight_api.extensions import TenantExtension, TenantContext, AuthenticationError
class JwtTenantExtension(TenantExtension):
def __init__(self, config: dict[str, str]):
super().__init__(config)
self.jwt_secret = config.get("jwt_secret")
if not self.jwt_secret:
raise ValueError("HINDSIGHT_API_TENANT_JWT_SECRET is required")
async def authenticate(self, context: RequestContext) -> TenantContext:
token = context.api_key
if not token:
raise AuthenticationError("Bearer token required")
try:
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
tenant_id = payload.get("tenant_id")
if not tenant_id:
raise AuthenticationError("Missing tenant_id in token")
return TenantContext(schema_name=f"tenant_{tenant_id}")
except jwt.InvalidTokenError as e:
raise AuthenticationError(str(e))
```
### Example: Custom HttpExtension
```python
from fastapi import APIRouter
from hindsight_api.extensions import HttpExtension
class MyHttpExtension(HttpExtension):
def get_router(self, memory: MemoryEngine) -> APIRouter:
router = APIRouter()
@router.get("/hello")
async def hello():
return {"message": "Hello from extension!"}
@router.post("/custom/{bank_id}/action")
async def custom_action(bank_id: str):
# Access memory engine for database operations
pool = await memory._get_pool()
# ... custom logic
return {"status": "ok"}
return router
```
Routes are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
### Example: Custom OperationValidatorExtension
```python
from hindsight_api.extensions import (
OperationValidatorExtension,
ValidationResult,
RetainContext,
RecallContext,
ReflectContext,
RetainResult,
)
class MyValidator(OperationValidatorExtension):
# Pre-operation validation (required)
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
# Implement your validation logic
return ValidationResult.accept()
# Or reject: return ValidationResult.reject("Reason")
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
# Post-operation hooks (optional)
async def on_retain_complete(self, result: RetainResult) -> None:
# Log usage, update metrics, send notifications, etc.
pass
```
### Example: Custom MCPExtension
```python
from mcp.server.fastmcp import FastMCP
from hindsight_api.extensions import MCPExtension
from hindsight_api.engine import MemoryEngine
class MyMCPExtension(MCPExtension):
async def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
@mcp.tool()
async def custom_search(query: str) -> str:
"""Custom MCP tool for specialized search."""
# Access memory engine for operations
pool = await memory._get_pool()
# ... custom logic
return f"Results for: {query}"
```
---
## Deploying Custom Extensions
### With Docker
Mount your extension package as a volume and set the environment variable:
```yaml
# docker-compose.yml
services:
hindsight-api:
image: vectorize/hindsight-api:latest
volumes:
- ./my_extensions:/app/my_extensions
environment:
- HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
- HINDSIGHT_API_TENANT_JWT_SECRET=${JWT_SECRET}
- PYTHONPATH=/app
```
Or build a custom image with your extensions:
```dockerfile
FROM vectorize/hindsight-api:latest
COPY my_extensions /app/my_extensions
ENV PYTHONPATH=/app
```
### Bare Metal
Install your extension package in the same Python environment as Hindsight:
```bash
# Install Hindsight
pip install hindsight-api
# Install your extension package
pip install ./my-extensions
# or
pip install my-extensions-package
# Configure
export HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
export HINDSIGHT_API_TENANT_JWT_SECRET=your-secret
# Run
hindsight-api
```
---
## Contributing Extensions
Custom extensions that solve common use cases are welcome contributions to the Hindsight project. If you've built an extension for:
- Authentication providers (OAuth, SAML, API gateways)
- Rate limiting or quota management
- Audit logging integrations
- Metrics exporters (Datadog, New Relic, etc.)
- Custom HTTP endpoints for specific platforms
Consider contributing it to the `hindsight_api.extensions.builtin` package. Open an issue or pull request on [GitHub](https://github.com/vectorize-io/hindsight) to discuss your extension.
@@ -0,0 +1,138 @@
---
sidebar_position: 1
slug: /
---
# Overview
## Why Hindsight?
AI agents forget everything between sessions. Every conversation starts from zero—no context about who you are, what you've discussed, or what the assistant has learned. This isn't just an implementation detail; it fundamentally limits what AI Agents can do.
**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
- **AI Agents need to consolidate knowledge** — A coding assistant that remembers "the user prefers functional programming" should consolidate this into an observation and weigh it 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 agents.
## What Hindsight Does
```mermaid
graph LR
subgraph app["<b>Your Application</b>"]
Agent[AI Agent]
end
subgraph hindsight["<b>Hindsight</b>"]
API[API Server]
subgraph bank["<b>Memory Bank</b>"]
direction TB
MentalModels[Mental Models]
Observations[Observations]
MemEnt[Memories & Entities]
Chunks[Chunks]
Documents[Documents]
MentalModels --> Observations --> MemEnt --> Chunks --> Documents
end
end
Agent -->|retain| API
Agent -->|recall| API
Agent -->|reflect| API
API --> bank
```
**Your AI agent** stores information via `retain()`, searches with `recall()`, and reasons with `reflect()` — all interactions with its dedicated **memory bank**
## Key Components
### Memory Types
Hindsight organizes knowledge into a hierarchy of facts and consolidated knowledge:
| Type | What it stores | Example |
|------|----------------|---------|
| **Mental Model** | User-curated summaries for common queries | "Team communication best practices" |
| **Observation** | Automatically consolidated knowledge from facts | "User was a React enthusiast but has now switched to Vue" (captures history) |
| **World Fact** | Objective facts received | "Alice works at Google" |
| **Experience Fact** | Bank's own actions and interactions | "I recommended Python to Bob" |
During reflect, the agent checks sources in priority order: **Mental Models → Observations → Raw Facts**.
### Multi-Strategy Retrieval (TEMPR)
Four search strategies run in parallel:
```mermaid
graph LR
Q[Query] --> S[Semantic]
Q --> K[Keyword]
Q --> G[Graph]
Q --> T[Temporal]
S --> RRF[RRF Fusion]
K --> RRF
G --> RRF
T --> RRF
RRF --> CE[Cross-Encoder]
CE --> R[Results]
```
| Strategy | Best for |
|----------|----------|
| **Semantic** | Conceptual similarity, paraphrasing |
| **Keyword (BM25)** | Names, technical terms, exact matches |
| **Graph** | Related entities, indirect connections |
| **Temporal** | "last spring", "in June", time ranges |
### Observation Consolidation
After memories are retained, Hindsight automatically consolidates related facts into **observations** — synthesized knowledge representations that capture patterns and learnings:
- **Automatic synthesis**: New facts are analyzed and consolidated into existing or new observations
- **Evidence tracking**: Each observation tracks which facts support it
- **Continuous refinement**: Observations evolve as new evidence arrives
### Mission, Directives & Disposition
Memory banks can be configured to shape how the agent reasons during `reflect`:
| Configuration | Purpose | Example |
|---------------|---------|---------|
| **Mission** | Natural language identity for the bank | "I am a research assistant specializing in ML. I prefer simplicity over cutting-edge." |
| **Directives** | Hard rules the agent must follow | "Never recommend specific stocks", "Always cite sources" |
| **Disposition** | Soft traits that influence reasoning style | Skepticism, literalism, empathy (1-5 scale) |
The **mission** tells Hindsight what knowledge to prioritize and provides context for reasoning. **Directives** are guardrails and compliance rules that must never be violated. **Disposition traits** subtly influence interpretation style.
These settings only affect the `reflect` operation, not `recall`.
## Next Steps
### Getting Started
- [**Quick Start**](/developer/api/quickstart) — Install and get up and running in 60 seconds
- [**RAG vs Hindsight**](/developer/rag-vs-hindsight) — See how Hindsight differs from traditional RAG with real examples
### Core Concepts
- [**Retain**](/developer/retain) — How memories are stored with multi-dimensional facts
- [**Recall**](/developer/retrieval) — How TEMPR's 4-way search retrieves memories
- [**Reflect**](/developer/reflect) — How mission, directives, and disposition shape reasoning
### API Methods
- [**Retain**](/developer/api/retain) — Store information in memory banks
- [**Recall**](/developer/api/recall) — Search and retrieve memories
- [**Reflect**](/developer/api/reflect) — Agentic reasoning with memory
- [**Mental Models**](/developer/api/mental-models) — User-curated summaries for common queries
- [**Memory Banks**](/developer/api/memory-banks) — Configure mission, directives, and disposition
- [**Documents**](/developer/api/documents) — Manage document sources
- [**Operations**](/developer/api/operations) — Monitor async tasks
### Deployment
- [**Server Setup**](/developer/installation) — Deploy with Docker Compose, Helm, or pip
@@ -0,0 +1,259 @@
# Installation
Hindsight can be deployed in several ways depending on your infrastructure and requirements.
:::tip Don't want to manage infrastructure?
**[Hindsight Cloud](https://vectorize.io/hindsight/cloud)** is a fully managed service that handles all infrastructure, scaling, and maintenance. We're onboarding design partners now — [request early access](https://vectorize.io/hindsight/cloud).
:::
## Prerequisites
### PostgreSQL with pgvector
Hindsight requires PostgreSQL with the **pgvector** extension for vector similarity search.
**By default**, Hindsight uses **pg0** — an embedded PostgreSQL that runs locally on your machine. This is convenient for development but **not recommended for production**.
**For production**, use an external PostgreSQL with pgvector:
- **Supabase** — Managed PostgreSQL with pgvector built-in
- **Neon** — Serverless PostgreSQL with pgvector
- **AWS RDS** / **Cloud SQL** / **Azure** — With pgvector extension enabled
- **Self-hosted** — PostgreSQL 14+ with pgvector installed
### LLM Provider
You need an LLM API key for fact extraction, entity resolution, and answer generation:
- **Groq** (recommended): Fast inference with `gpt-oss-20b`
- **OpenAI**: GPT-4o, GPT-4o-mini
- **Ollama**: Run models locally
See [Models](./models) for detailed comparison and configuration.
---
## Docker
**Best for**: Quick start, development, small deployments
Run everything in one container with embedded PostgreSQL:
```bash
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
- **API Server**: http://localhost:8888
- **Control Plane** (Web UI): http://localhost:9999
### Docker Image Variants
Hindsight provides two image variants with different size/capability tradeoffs:
| Variant | Size (AMD64) | Size (ARM64) | Use Case |
|---------|--------------|--------------|----------|
| **Full** (`latest`) | ~9 GB | ~3.7 GB | Includes local ML models (embeddings, reranking) |
| **Slim** (`slim`) | ~500 MB | ~500 MB | Requires external embedding/reranking providers |
**Full image** (default):
```bash
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
ghcr.io/vectorize-io/hindsight:latest
```
- ✅ Works out of the box with local ML models
- ✅ No additional services needed
- ❌ Larger image size (AMD64 includes CUDA libraries for GPU support)
**Slim image**:
```bash
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai \
-e HINDSIGHT_API_RERANKER_PROVIDER=cohere \
-e HINDSIGHT_API_COHERE_API_KEY=$COHERE_API_KEY \
ghcr.io/vectorize-io/hindsight:slim
```
- ✅ Dramatically smaller image (~95% reduction on AMD64)
- ✅ Faster pull/deploy times
- ✅ Lower memory footprint
- ❌ Requires external embedding/reranking services (OpenAI, Cohere, TEI)
**When to use slim:**
- Cloud deployments where image size matters
- Using managed embedding services (OpenAI, Cohere)
- Running on Text Embeddings Inference (TEI) infrastructure
- Kubernetes environments with fast pull requirements
:::warning Slim Image Requires External Providers
If you run the slim image **without** setting external embedding providers, you'll see this error:
```
ImportError: sentence-transformers is required for LocalSTEmbeddings.
Install it with: pip install sentence-transformers
```
**Fix:** Always set embedding and reranking providers when using slim images:
```bash
-e HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
-e HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
-e HINDSIGHT_API_RERANKER_PROVIDER=cohere
-e HINDSIGHT_API_COHERE_API_KEY=xxx
```
:::
See [Configuration](./configuration#embeddings-and-reranking) for all embedding provider options.
### Available Tags
```bash
# Standalone (API + Control Plane)
ghcr.io/vectorize-io/hindsight:latest # Full, latest release
ghcr.io/vectorize-io/hindsight:slim # Slim, latest release
ghcr.io/vectorize-io/hindsight:0.4.9 # Full, specific version
ghcr.io/vectorize-io/hindsight:0.4.9-slim # Slim, specific version
# API only
ghcr.io/vectorize-io/hindsight-api:latest
ghcr.io/vectorize-io/hindsight-api:slim
# Control Plane only
ghcr.io/vectorize-io/hindsight-control-plane:latest
```
---
## Helm / Kubernetes
**Best for**: Production deployments, auto-scaling, cloud environments
```bash
# Install with built-in PostgreSQL
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set api.llm.provider=groq \
--set api.llm.apiKey=gsk_xxxxxxxxxxxx \
--set postgresql.enabled=true
# Or use external PostgreSQL
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set api.llm.provider=groq \
--set api.llm.apiKey=gsk_xxxxxxxxxxxx \
--set postgresql.enabled=false \
--set api.database.url=postgresql://user:pass@postgres.example.com:5432/hindsight
# Install a specific version
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight --version 0.1.3
# Upgrade to latest
helm upgrade hindsight oci://ghcr.io/vectorize-io/charts/hindsight
```
**Requirements**:
- Kubernetes cluster (GKE, EKS, AKS, or self-hosted)
- Helm 3.8+
### Distributed Workers
For high-throughput deployments, enable dedicated worker pods to scale task processing independently:
```bash
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set worker.enabled=true \
--set worker.replicaCount=3
```
See [Services - Worker Service](./services#worker-service) for configuration details and architecture.
See the [Helm chart values.yaml](https://github.com/vectorize-io/hindsight/tree/main/helm/hindsight/values.yaml) for all chart options.
---
## Bare Metal (pip)
**Best for**: Custom deployments, integration into existing Python applications
### Install
```bash
pip install hindsight-all
```
### Run with Embedded Database
For development and testing, Hindsight can run with an embedded PostgreSQL (pg0):
```bash
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
hindsight-api
```
This creates a database in `~/.hindsight/data/` and starts the API on http://localhost:8888.
### Run with External PostgreSQL
For production, connect to your own PostgreSQL instance:
```bash
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
hindsight-api
```
**Note**: The database must exist and have pgvector enabled (`CREATE EXTENSION vector;`).
### CLI Options
```bash
hindsight-api --port 9000 # Custom port (default: 8888)
hindsight-api --host 127.0.0.1 # Bind to localhost only
hindsight-api --workers 4 # Multiple worker processes
hindsight-api --log-level debug # Verbose logging
```
### Control Plane
The Control Plane (Web UI) can be run standalone using npx:
```bash
npx @vectorize-io/hindsight-control-plane --api-url http://localhost:8888
```
This connects to your running API server and provides a visual interface for managing memory banks, exploring entities, and testing queries.
#### Options
| Option | Environment Variable | Default | Description |
|--------|---------------------|---------|-------------|
| `-p, --port` | `PORT` | 9999 | Port to listen on |
| `-H, --hostname` | `HOSTNAME` | 0.0.0.0 | Hostname to bind to |
| `-a, --api-url` | `HINDSIGHT_CP_DATAPLANE_API_URL` | http://localhost:8888 | Hindsight API URL |
#### Examples
```bash
# Run on custom port
npx @vectorize-io/hindsight-control-plane --port 9999 --api-url http://localhost:8888
# Using environment variables
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com
npx @vectorize-io/hindsight-control-plane
# Production deployment
PORT=80 HINDSIGHT_CP_DATAPLANE_API_URL=https://api.hindsight.io npx @vectorize-io/hindsight-control-plane
```
---
## Next Steps
- [Configuration](./configuration.md) — Environment variables and settings
- [Models](./models.md) — ML models and providers
- [Monitoring](./monitoring.md) — Metrics and observability
@@ -0,0 +1,204 @@
---
sidebar_position: 5
---
# MCP Server
Hindsight includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that allows AI assistants to store and retrieve memories directly.
## Access
The MCP server is **enabled by default** and mounted at `/mcp` on the API server. Each memory bank has its own MCP endpoint:
```
http://localhost:8888/mcp/{bank_id}/
```
For example, to connect to the memory bank `alice`:
```
http://localhost:8888/mcp/alice/
```
To disable the MCP server, set the environment variable:
```bash
export HINDSIGHT_API_MCP_ENABLED=false
```
## Authentication
By default, the MCP endpoint is **open** (no authentication required).
To enable authentication, configure the API key tenant extension:
```bash
export HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
export HINDSIGHT_API_TENANT_API_KEY=your-secret-key
```
When authentication is enabled, include your API key in the `Authorization` header:
### Claude Code
```bash
claude mcp add --transport http hindsight http://localhost:8888/mcp \
--header "Authorization: Bearer your-secret-key" \
--header "X-Bank-Id: my-bank"
```
### Claude Desktop
Add to `~/.claude_desktop_config.json`:
```json
{
"mcpServers": {
"hindsight": {
"url": "http://localhost:8888/mcp",
"headers": {
"Authorization": "Bearer your-secret-key",
"X-Bank-Id": "my-bank"
}
}
}
}
```
### Direct HTTP Request
```bash
curl -X POST http://localhost:8888/mcp \
-H "Authorization: Bearer your-secret-key" \
-H "X-Bank-Id: my-bank" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'
```
If the key is missing or invalid, requests will receive a `401 Unauthorized` response.
## Bank Selection
Specify the memory bank via:
1. **X-Bank-Id header** (recommended): `--header "X-Bank-Id: my-bank"`
2. **URL path**: `http://localhost:8888/mcp/my-bank/`
3. **Default**: Uses `HINDSIGHT_MCP_BANK_ID` env var (default: "default")
## Per-Bank Endpoints
Unlike traditional MCP servers where tools require explicit identifiers, Hindsight uses **per-bank endpoints**. The `bank_id` is part of the URL path, so tools don't need to specify which bank to use—it's implicit from the connection.
This design:
- **Simplifies tool usage** — no need to pass `bank_id` with every call
- **Enforces isolation** — each MCP connection is scoped to a single bank
- **Enables multi-tenant setups** — connect different users to different endpoints
---
## Available Tools
### retain
Store information to long-term memory.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | The fact or memory to store |
| `context` | string | No | Category for the memory (default: `general`) |
**Example:**
```json
{
"name": "retain",
"arguments": {
"content": "User prefers Python over JavaScript for backend development",
"context": "programming_preferences"
}
}
```
**When to use:**
- User shares personal facts, preferences, or interests
- Important events or milestones are mentioned
- Decisions, opinions, or goals are stated
- Work context or project details are discussed
---
### recall
Search memories to provide personalized responses.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Natural language search query |
| `max_results` | integer | No | Maximum results to return (default: 10) |
**Example:**
```json
{
"name": "recall",
"arguments": {
"query": "What are the user's programming language preferences?"
}
}
```
**Response:**
```json
{
"results": [
{
"id": "fact_abc123",
"text": "User prefers Python over JavaScript for backend development",
"type": "world",
"context": "programming_preferences",
"event_date": null
}
]
}
```
**When to use:**
- Start of conversation to recall relevant context
- Before making recommendations
- When user asks about something they may have mentioned before
- To provide continuity across conversations
---
### reflect
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | The question or topic to reflect on |
| `context` | string | No | Optional context about why this reflection is needed |
| `budget` | string | No | Search budget: `low`, `mid`, or `high` (default: `low`) |
**Example:**
```json
{
"name": "reflect",
"arguments": {
"query": "Based on my past decisions, what architectural style do I prefer?",
"budget": "mid"
}
}
```
**When to use:**
- When reasoned analysis is needed, not just fact retrieval
- Questions like "What should I do?" rather than "What did I say?"
- Synthesizing patterns across multiple memories
---
## Integration with AI Assistants
The MCP server can be used with any MCP-compatible AI assistant. See the [Authentication](#authentication) section above for Claude Code and Claude Desktop configuration examples.
Each user can have their own configuration pointing to their personal memory bank using either:
- The `X-Bank-Id` header (recommended)
- A bank-specific URL path like `/mcp/alice/`

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