# Technical Documentation: vectorize-io/hindsight

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/vectorize-io/hindsight)

### Overview (README.md)

<div align="center">



[Documentation](https://hindsight.vectorize.io) • [Integrations](https://hindsight.vectorize.io/integrations) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Benchmarks](https://benchmarks.hindsight.vectorize.io/) • [Paper](https://arxiv.org/abs/2512.12818) • [Hindsight Cloud](https://ui.hindsight.vectorize.io)

[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[](https://pypi.org/project/hindsight-api/)
[](https://pypi.org/project/hindsight-client/)
[](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[](https://vectorize.io/slack)
[](https://opensource.org/licenses/MIT)
<br/>
<p align="center">
 <a href="https://www.star-history.com/vectorize-io/hindsight"><img src="https://api.star-history.com/badge?repo=vectorize-io/hindsight&type=rank" alt="Star History Rank" /> <img src="https://api.star-history.com/badge?repo=vectorize-io/hindsight&type=trending" alt="GitHub Trending Repository of the Day" /></a>
</p>

</div>

---

## What is Hindsight?

Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.

<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>

It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.

**Contents**

- [Memory Performance & Accuracy](#memory-performance--accuracy)
- [Quick Start](#quick-start) — [server](#1-start-a-server) · [clients](#2-connect-a-client) · [platforms](#supported-platforms) · [embedded](#python-embedded-no-server-required)
- [Adding Hindsight to Your Agent](#adding-hindsight-to-your-agent) — [LLM Wrapper](#llm-wrapper-2-lines-of-code) · [integrations](#integrations) · [coding agents](#coding-agents) · [MCP](#mcp-server)
- [Core Concepts](#core-concepts) — [memory types](#memory-types) · [retain / recall / reflect](#the-three-operations) · [observations](#observations) · [mental models & knowledge pages](#mental-models--knowledge-pages) · [banks](#memory-banks)
- [Use Cases](#use-cases)
- [Running in Production](#running-in-production)
- [Resources](#resources)

---

## Memory Performance & Accuracy

Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:



> Live, continuously updated results — including per-model accuracy, latency and cost — are published at [benchmarks.hindsight.vectorize.io](https://benchmarks.hindsight.vectorize.io/).

The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.

Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.

---

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

### 1. Start a server

#### Docker (recommended)

```bash
export OPENAI_API_KEY=sk-xxx

docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8888 -p 9999:9999 \
  -e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
  -v hindsight-data:/home/hindsight/.pg0 \
  ghcr.io/vectorize-io/hindsight:latest
```

>API: http://localhost:8888
>UI: http://localhost:9999

Hindsight works with **25+ LLM providers** via `HINDSIGHT_API_LLM_PROVIDER` — hosted (`openai`, `anthropic`, `gemini`, `groq`, `bedrock`, `vertexai`, `minimax`, `deepseek`, `atlas`, `meta`, …), fully local (`ollama`, `lmstudio`, `llamacpp`), any OpenAI-compatible endpoint, and gateways (`litellm`, `litellmrouter`) that reach the rest. Existing subscriptions work too: `openai-codex` (ChatGPT Plus/Pro), `claude-code` (Claude Pro/Max), `cursor` (Cursor) and `github-copilot` (GitHub Copilot) need no API key. See [supported models](https://hindsight.vectorize.io/developer/models).

#### Docker (external PostgreSQL)

```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_DB_PASSWORD=choose-a-password
cd docker/docker-compose
docker compose up
```

> Oracle AI Database is also supported for enterprise deployments with full feature parity. See the [storage documentation](https://hindsight.vectorize.io/developer/storage) for details.

#### Bare metal (pip)

```bash
pip install hindsight-api
export HINDSIGHT_API_LLM_API_KEY=sk-xxx

hindsight-api
```

#### Kubernetes (Helm)

```bash
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
  --set api.llm.provider=openai \
  --set api.llm.apiKey=sk-xxx \
  --set postgresql.enabled=true
```

#### Managed (no server)

[Hindsight Cloud](https://vectorize.io/pricing) is the hosted option: managed infrastructure that scales automatically, plus a dashboard, backups, team collaboration and a 99.9% uptime SLA. Billing is usage-based with free credits to start — no fixed monthly or per-seat fee. Point any client at `https://api.hindsight.vectorize.io` with your API key and skip the deployment entirely.

[Compare self-hosted, Cloud and Enterprise →](https://vectorize.io/pricing) · [Sign up →](https://ui.hindsight.vectorize.io)

All options, including Windows and air-gapped setups, are covered in the [installation guide](https://hindsight.vectorize.io/developer/installation).

### 2. Connect a client

```bash
pip install hindsight-client -U                                  # Python
npm install @vectorize-io/hindsight-client                        # Node.js / TypeScript
go get github.com/vectorize-io/hindsight/hindsight-clients/go     # Go
curl -fsSL https://hindsight.vectorize.io/get-cli | bash          # CLI
```

#### Python

```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 / TypeScript

```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');

const main = async () => {
  const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });

  await client.retain('my-bank', 'Alice loves hiking in Yosemite');

  const results = await client.recall('my-bank', 'What does Alice like?');
  console.log(results);
}

main();
```

Full reference: [Python](https://hindsight.vectorize.io/sdks/python) · [Node.js](https://hindsight.vectorize.io/sdks/nodejs) · [Go](https://hindsight.vectorize.io/sdks/go) · [CLI](https://hindsight.vectorize.io/sdks/cli) · [REST API](https://hindsight.vectorize.io/api-reference)

### Supported Platforms

| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |

⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.

### Python Embedded (no server required)

```bash
pip install hindsight-all -U
```

On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).

```python
import os
from hindsight import HindsightServer, HindsightClient

with HindsightServer(
    llm_provider="openai",
    llm_model="gpt-5-mini",
    llm_api_key=os.environ["OPENAI_API_KEY"]
) as server:
    client = HindsightClient(base_url=server.url)
    client.retain(bank_id="my-bank", content="Alice works at Google")
    results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```

A [Node.js equivalent](https://hindsight.vectorize.io/sdks/hindsight-all-npm) and a [daemon CLI](https://hindsight.vectorize.io/sdks/embed) are also available.

---

## Adding Hindsight to Your Agent

### LLM Wrapper (2 lines of code)

The easiest way to add memory to an existing agent is the LLM Wrapper. Swap your LLM client for a wrapped one — memories are then stored and retrieved automatically on every call, with no other changes to your code.

```bash
pip install hindsight-litellm
```

```python
from openai import OpenAI
from hindsight_litellm import wrap_openai

# Wrap your existing LLM client and you're done.
# Defaults to Hindsight Cloud; pass hindsight_api_url for a self-hosted server.
client = wrap_openai(
    OpenAI(),
    bank_id="user-123",
    hindsight_api_url="http://localhost:8888",
)

# Hindsight recalls relevant memories before the call
# and retains the conversation after it.
response = client.chat.completions.create(
    model="gpt-5-mini",
    messages=[{"role": "user", "content": "What do you know about me?"}],
)
```

`wrap_anthropic()` does the same for the Anthropic SDK, and every setting — bank, recall budget, fact types, reflect instead of recall — can be overridden per call with `hindsight_*` kwargs. LiteLLM sits underneath, so the same integration covers **100+ models**. See the [LiteLLM integration](https://hindsight.vectorize.io/sdks/integrations/litellm).

If you need explicit control over *when* memories are stored and recalled, use the [SDKs or REST API](#2-connect-a-client) directly instead.

### Integrations

**60+ integrations** — most need no code changes.

| | |
|---|---|
| **Coding agents** | [Claude Code](https://hindsight.vectorize.io/sdks/integrations/claude-code) · [Codex](https://hindsight.vectorize.io/sdks/integrations/codex) · [Cursor](https://hindsight.vectorize.io/sdks/integrations/cursor) · [GitHub Copilot](https://hindsight.vectorize.io/sdks/integrations/github-copilot) · [opencode](https://hindsight.vectorize.io/sdks/integrations/opencode) · [Cline](https://hindsight.vectorize.io/sdks/integrations/cline) · [Aider](https://hindsight.vectorize.io/sdks/integrations/aider) · [Zed](https://hindsight.vectorize.io/sdks/integrations/zed) · [Continue](https://hindsight.vectorize.io/sdks/integrations/continue) · [Roo Code](https://hindsight.vectorize.io/sdks/integrations/roo-code) · [OpenHands](https://hindsight.vectorize.io/sdks/integrations/openhands) |
| **Agent frameworks** | [LangGraph / LangChain](https://hindsight.vectorize.io/sdks/integrations/langgraph) · [LlamaIndex](https://hindsight.vectorize.io/sdks/integrations/llamaindex) · [CrewAI](https://hindsight.vectorize.io/sdks/integrations/crewai) · [Pydantic AI](https://hindsight.vectorize.io/sdks/integrations/pydantic-ai) · [OpenAI Agents SDK](https://hindsight.vectorize.io/sdks/integrations/openai-agents) · [Google ADK](https://hindsight.vectorize.io/sdks/integrations/google-adk) · [Agno](https://hindsight.vectorize.io/sdks/integrations/agno) · [Strands](https://hindsight.vectorize.io/sdks/integrations/strands) · [AutoGen](https://hindsight.vectorize.io/sdks/integrations/autogen) · [Microsoft Agent Framework](https://hindsight.vectorize.io/sdks/integrations/agent-framework) · [Vercel AI SDK](https://hindsight.vectorize.io/sdks/integrations/ai-sdk) · [Haystack](https://hindsight.vectorize.io/sdks/integrations/haystack) |
| **No-code / low-code** | [n8n](https://hindsight.vectorize.io/sdks/integrations/n8n) · [Zapier](https://hindsight.vectorize.io/sdks/integrations/zapier) · [Dify](https://hindsight.vectorize.io/sdks/integrations/dify) · [Flowise](https://hindsight.vectorize.io/sdks/integrations/flowise) |
| **Apps & tools** | [ChatGPT](https://hindsight.vectorize.io/sdks/integrations/chatgpt) · [Perplexity](https://hindsight.vectorize.io/sdks/integrations/perplexity) · [Obsidian](https://hindsight.vectorize.io/sdks/integrations/obsidian) · [Pipecat](https://hindsight.vectorize.io/sdks/integrations/pipecat) · [Vapi](https://hindsight.vectorize.io/sdks/integrations/vapi) |

👉 [**Browse all integrations**](https://hindsight.vectorize.io/integrations)

### Coding Agents

One package gives CLI coding agents long-term project memory: a per-repo bank built automatically from git history and past sessions, injected into the agent as it starts working, plus curated knowledge pages covering architecture, conventions and in-flight work.

```bash
npx @vectorize-io/hindsight-coding-agents install all          # every detected agent, wired natively
npx @vectorize-io/hindsight-coding-agents install claude-code  # or just one
```

Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, pi, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).

### MCP Server

Every server ships a built-in [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, one per bank, enabled by default:

```
http://localhost:8888/mcp/{bank_id}/
```

Point any MCP client at it to expose retain, recall and reflect as tools. See the [MCP server docs](https://hindsight.vectorize.io/developer/mcp-server).

---

## Core Concepts



### Memory Types

Most agent memory implementations rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:

- **World facts:** facts about the world ("The stove gets hot")
- **Experiences:** the agent's own experiences ("I touched the stove and it really hurt")
- **Observations:** consolidated, evidence-backed beliefs formed from many memories
- **Mental models:** learned understanding of the agent's world, synthesized from observations and facts

Memories live in **banks**. When memories are added, they are pushed into either the world facts or the experiences pathway, then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.

### The Three Operations

#### Retain

The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.

```python
client.retain(
    bank_id="my-bank",
    content="Alice got promoted to senior engineer",
    context="career update",
    timestamp="2025-06-15T10:00:00Z",
)
```

Behind the scenes, retain uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.

<video src="https://github.com/user-attachments/assets/0555177d-6635-467d-97cb-9dcddb999b15" controls muted></video>

[Retain docs →](https://hindsight.vectorize.io/developer/retain)

#### Recall

The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)

```python
client.recall(bank_id="my-bank", query="What does Alice do?")
client.recall(bank_id="my-bank", query="What happened in June?")   # temporal
```

Recall performs 4 retrieval strategies in parallel:
- Semantic: Vector similarity
- Keyword: BM25 exact matching
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering

<video src="https://github.com/user-attachments/assets/1c02eac8-1c5a-4e42-9a00-7201d44975a0" controls muted></video>

The individual results are merged, ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model, then trimmed as needed to fit within the token limit.

[Recall docs →](https://hindsight.vectorize.io/developer/retrieval)

#### Reflect

The reflect operation performs a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world — or to answer a question that needs deep thinking rather than lookup.

```python
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```

For example, reflect supports use cases such as:

- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.

<video src="https://github.com/user-attachments/assets/1dd8aa20-5ad0-4536-823e-0fadf8051d57" controls muted></video>

[Reflect docs →](https://hindsight.vectorize.io/developer/reflect)

### Observations

Retained facts don't stay a flat pile. In the background, Hindsight consolidates related facts into **observations** — deduplicated beliefs the bank has built up over time. Each observation keeps its supporting evidence with exact quotes and a proof count, and is *refined* rather than overwritten when new evidence arrives, so new information strengthens, weakens or extends an existing belief instead of silently replacing it.

[Observations docs →](https://hindsight.vectorize.io/developer/observations)

### Mental Models & Knowledge Pages

A **mental model** is a standing answer to a question about a bank ("What are this user's preferences?"). You define the question once; Hindsight writes the answer, stores it, and rewrites it in the background as the bank learns more. Reading one is a database read — no retrieval, no LLM call — so an agent can boot with a page of settled knowledge instead of rediscovering it every session.

**Knowledge pages** are mental models with the mechanics hidden: living documents a bank writes about itself, organized in folders like a wiki, searchable, and projectable onto disk as ordinary markdown. Supply a name and a question; every other decision is a default you can override.

[Mental models →](https://hindsight.vectorize.io/developer/mental-models) · [Knowledge pages →](https://hindsight.vectorize.io/developer/knowledge-pages)

### Memory Banks

A **bank** is an isolated memory store — one "brain" for one user, agent, or project. Isolation is strict: no cross-bank leakage. Banks carry background context and **disposition traits** (skepticism, literalism, empathy) that shape how reflect reasons over their memories, and can be created from declarative [bank templates](https://hindsight.vectorize.io/developer/api/bank-templates).

Two more things worth knowing:

- **Multilingual by default.** Input language is detected and preserved end to end — facts stay in their original language and entities keep their native script (张伟 stays 张伟, not "Zhang Wei"). [Docs →](https://hindsight.vectorize.io/developer/multilingual)
- **Memory Defense.** An opt-in, per-bank policy that scans every retain for secrets and PII against 45 patterns and either redacts the match (`[REDACTED:github_token]`) or blocks the item before it reaches storage. [Docs →](https://hindsight.vectorize.io/developer/memory-defense)

---

## Use Cases

Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.

### Per-User Memories and Chat History

One of the simpler use cases you can use Hindsight for is to personalize AI chatbots and other conversational agents by storing and recalling memories associated with individual users.

The requirements for this use case usually look something like this:



<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>

Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.



More patterns in the [Cookbook](https://hindsight.vectorize.io/cookbook) and [Best Practices](https://hindsight.vectorize.io/best-practices).

---

## Running in Production

| | |
|---|---|
| **Storage** | PostgreSQL + pgvector, or Oracle AI Database 23ai with full feature parity — [storage](https://hindsight.vectorize.io/developer/storage) |
| **Configuration** | Hierarchical: global env vars → per-tenant → per-bank — [configuration](https://hindsight.vectorize.io/developer/configuration) |
| **Monitoring** | Prometheus metrics and dashboards for LLM calls, tokens and latency — [monitoring](https://hindsight.vectorize.io/developer/monitoring) |
| **Operations** | Admin CLI for migrations, bank repair and stuck operations — [admin CLI](https://hindsight.vectorize.io/developer/admin-cli) |
| **Events** | Webhooks for retain, consolidation and refresh lifecycle events — [webhooks](https://hindsight.vectorize.io/developer/api/webhooks) |
| **Extensibility** | Tenant, auth and storage extension points — [extensions](https://hindsight.vectorize.io/developer/extensions) |
| **Managed** | Skip all of it with [Hindsight Cloud](https://vectorize.io/pricing) — managed, usage-based, 99.9% uptime SLA |

---

## Resources

**Documentation:**
- [Docs](https://hindsight.vectorize.io) · [FAQ](https://hindsight.vectorize.io/faq) · [Best Practices](https://hindsight.vectorize.io/best-practices) · [Cookbook](https://hindsight.vectorize.io/cookbook) · [Blog](https://hindsight.vectorize.io/blog)
- [Paper](https://arxiv.org/abs/2512.12818) · [Benchmarks](https://benchmarks.hindsight.vectorize.io/) · [RAG vs Memory](https://hindsight.vectorize.io/developer/rag-vs-hindsight)

**Clients:**
- [Python](https://hindsight.vectorize.io/sdks/python) · [Node.js](https://hindsight.vectorize.io/sdks/nodejs) · [Go](https://hindsight.vectorize.io/sdks/go) · [CLI](https://hindsight.vectorize.io/sdks/cli) · [REST API](https://hindsight.vectorize.io/api-reference)

**Community:**
- [Slack](https://vectorize.io/slack)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)

---

## Star History

[](https://www.star-history.com/?repos=vectorize-io%2Fhindsight&type=date&legend=top-left)

---

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md).

## License

MIT — see [LICENSE](./LICENSE)

---

Built by [Vectorize.io](https://vectorize.io)

<img src="https://umami-pixel.chris-latimer.workers.dev/?id=a8b043e6-6964-454d-80df-69b69d3f0d50&host=github.com&url=/vectorize-io/hindsight" width="1" height="1" alt="" />

---

### Hindsight Docs Docs Cloud Enterprise Overview (`hindsight-docs/docs/cloud-enterprise/overview.md`)

---
title: Ways to run Hindsight
sidebar_label: Overview
description: Run open-source Hindsight yourself, use the managed Hindsight Cloud, or get a dedicated Hindsight Enterprise deployment.
---

# Ways to run Hindsight

Hindsight runs three ways. All of them use the same memory engine: the same retain, recall and reflect, the same mental models and knowledge pages, and the same API and SDKs. You can start on one and move to another later.

| | Self-hosted (open source) | Hindsight Cloud | Hindsight Enterprise |
|---|---|---|---|
| **What it is** | The open-source engine, run by you | Managed, self-serve, multi-tenant service | A single-tenant deployment, installed and run with Vectorize |
| **Where it runs** | Anywhere: your laptop, Docker, Kubernetes | Hindsight Cloud | Our cloud, your AWS, Google Cloud or Azure account, or your data center |
| **Who runs it** | You | Vectorize | Vectorize, or you with Vectorize's help on-premises |
| **Memory store** | PostgreSQL (or Oracle) | Managed by Vectorize | PostgreSQL, or [Hindsight Memlake](./enterprise.md#hindsight-memlake) on object storage |
| **Models** | Bring your own LLM and embeddings | Included | Included, or your own (Bedrock, Vertex AI, Azure OpenAI) |
| **Sign-in and access** | API keys you configure | Console, teams and API keys | Plus SSO, enforced MFA and private networking |
| **Pricing** | Free (MIT license) | Pay as you go, from prepaid credits | Contract |
| **Get started** | [Installation](/developer/installation) | [Sign up](https://ui.hindsight.vectorize.io/signup) | [Book a call](https://calendly.com/d/dvy5-v3f-jhg?utm_source=oss_docs&utm_campaign=enterprise_docs) |

## How to choose

- **Self-hosted** if you want full control, want to try Hindsight locally, or already run Postgres and your own models. These docs cover it: start with [Installation](/developer/installation).
- **[Hindsight Cloud](./hindsight-cloud.md)** if you want memory for your agents in minutes, with nothing to deploy and the models included.
- **[Hindsight Enterprise](./enterprise.md)** if you need your own deployment: single-tenant isolation, a region or cloud account of your choice, private networking, SSO and compliance features, contracted support, or memory at a scale where [Hindsight Memlake](./enterprise.md#hindsight-memlake) pays off.

Hindsight Cloud and Enterprise have their own documentation at [docs.hindsight.vectorize.io](https://docs.hindsight.vectorize.io).

---

### Hindsight Docs Docs Developer Index (`hindsight-docs/docs/developer/index.mdx`)

---
sidebar_position: 1
slug: /
title: Overview
hide_title: true
description: State of the art long-term memory for your agents. Hindsight gives AI agents memory that learns across sessions.
hide_table_of_contents: true
---

import HomeHero from '@site/src/components/HomeHero';
import HomeFlow from '@site/src/components/HomeFlow';
import HomeBenchmarks from '@site/src/components/HomeBenchmarks';
import SkillBanner from '@site/src/components/SkillBanner';
import Figure from '@site/src/components/Figure';
import whatHindsightDoes from '@site/figures/what-hindsight-does.json';

{/* The page's own `# Overview` H1 is suppressed via hide_title: this is the site
    root, and the hero's headline is the H1 a visitor and a crawler should see.
    The sidebar and page metadata still read "Overview" from the frontmatter. */}

<HomeHero />

## Benchmarks

<HomeBenchmarks />

{/* Retrieval accuracy only. The coding-agent chart opens inside the coding-agents
    card above, where it is evidence for a path the reader has just been shown,
    and is explained in full on /sdks/integrations/coding-agents. */}

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

{/* Rendered here rather than by the DocBreadcrumbs wrapper (which serves every
    other page): at the top it sat between the navbar and the hero band as a
    bordered strip and broke the full-bleed, and it lands better as the answer
    to the problem the section above just described. */}
<SkillBanner />

## How It Works

{/* The router: three kinds of visitor land on this page — one who has never
    heard of agent memory, one looking for the coding-agent plugin, one looking
    for the Hermes/OpenClaw plugin — and this is where each of them can see
    their own path. It opens "How It Works" because the cards answer "what does
    this look like for me" before the sections below answer "how". */}
<HomeFlow />

### Memory Types

Hindsight does not store conversations. It extracts what was said into typed
facts and then builds on them:

- **World fact** — an objective claim it was told. *"Alice works at Google."*
- **Experience fact** — something the bank itself did. *"I recommended Python to Bob."*
- **Observation** — a belief consolidated from many facts, with its evidence and
  its history. *"User was a React enthusiast, has now switched to Vue."*
- **Mental model** — a curated summary you write for a question you ask often.
- **Knowledge page** — a living document the bank writes about itself.

Facts are not a list. Each is linked to the entities it mentions and to the other
facts that share them, which is what makes "where does Alice work?" answerable
from two facts that were never stored together — the graph at the top of this
page is a sample bank's; hover a memory to see what it links to.

### Multi-Strategy Retrieval

`recall()` runs four searches **in parallel**, because no single one handles
every question:

- **Semantic** — meaning rather than wording, so "Alice's job" finds "Alice works as a software engineer"
- **Keyword (BM25)** — the names and technical terms an embedding blurs together; five pluggable Postgres backends, including one that works on a Citus cluster
- **Graph** — entity links, so a fact reachable in two hops comes back even when it shares no words with the query
- **Temporal** — time expressions parsed into a window, then filled by relevance *and spread across the range*, so "what happened in 2023?" is not all from December

Then the part that matters more than any single arm:

- **Fused by rank, not score** — a memory several strategies agree on wins, and no arm's scoring scale can dominate the others
- **Re-ranked by a cross-encoder** that reads the query and the memory together
- **Cut to a token budget, not a top-k** — you say how much context you can afford and Hindsight fills it, because agents budget in tokens and not in result counts

See [Recall](/developer/retrieval) for the strategies in full.

### Observation Consolidation

A background worker keeps turning raw facts into durable beliefs:

- **Deduplicated** — overlapping facts merge into one observation instead of piling up as repeats
- **Evidence-grounded** — each observation points at the memories that support it, with exact quotes and a proof count
- **Refined, not overwritten** — new evidence updates an observation and its history is kept, so you can see a belief change
- **Freshness-aware** — when memories have landed but not yet been consolidated, `reflect` treats the affected observations as stale and checks them against raw facts before trusting them

### Knowledge Pages

Observations answer one question at a time. A **knowledge page** is a living
document the bank writes about itself — "What are the components here?", "What's
our error-handling convention?" — rewritten incrementally as consolidation
produces new knowledge in its scope:

- **A wiki, not a blob** — pages live in a tree of folders, browsable and searchable, each answering one question
- **Built from observations** — synthesized from consolidated beliefs rather than raw conversation, so a page is not a transcript summary
- **Never self-citing** — a page never reads another page, so they cannot cite each other into a feedback loop
- **Real files when you want them** — `hindsight fs mount` projects the tree onto disk as ordinary markdown, so `grep`, an editor or an agent's file tools all work with no SDK

See [Knowledge Pages](/developer/knowledge-pages) for the full model.

### Reflect

`recall()` returns memories. `reflect()` returns an **answer**, by running an
agentic loop over the bank rather than a single query:

- **It gathers its own evidence** — the agent decides what it needs and calls its own tools, up to ten rounds, and cannot answer before it has retrieved something
- **It checks sources in priority order** — mental models and knowledge pages first, then observations, and only then raw facts, so it reads the distilled answer before the transcript
- **It cites what it used** — and only IDs it actually retrieved can be cited

What makes two banks answer the same question differently is their configuration:

- **Mission** — the bank's identity in plain language, which tells it what to prioritise. *"I am a research assistant specializing in ML. I prefer simplicity over cutting-edge."*
- **Directives** — hard rules it must never break. *"Never recommend specific stocks."*
- **Disposition** — skepticism, literalism and empathy on a 1–5 scale, shaping how it interprets what it finds

These shape `reflect` only. `recall` returns the same memories whoever is asking.

See [Reflect](/developer/reflect) for the loop in detail.

## Architecture Deep Dive

Everything above, end to end and in motion: what a document turns into on the
way in, what each of the three operations touches, and what the worker changes
behind them. Play it, or step through it at your own pace.

<Figure doc={whatHindsightDoes} />

## Integrations

**Coding agents** — [one install](/sdks/integrations/coding-agents) wires 20+
harnesses (Claude Code, Codex CLI, Cursor CLI, opencode, Copilot CLI and more) to
a per-repo memory bank.

**Personal agents** — [Hermes](/sdks/integrations/hermes) and
[OpenClaw](/sdks/integrations/openclaw) install Hindsight as their memory
provider: every turn recalls what matters before answering and retains what was
said afterwards. The Hermes desktop app configures it
[in Settings](/sdks/integrations/hermes-desktop), no terminal.

Everything else — frameworks, MCP servers, tools — is in the
[Integrations Hub](/integrations).

## 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 the 4-way parallel 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

---

### Hindsight Docs Docs Developer Api Quickstart (`hindsight-docs/docs/developer/api/quickstart.mdx`)

---
sidebar_position: 0
---

# Quick Start

Get up and running with Hindsight in 60 seconds.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';
import {ClientsGrid} from '@site/src/components/SupportedGrids';

{/* Import raw source files */}
import quickstartPy from '!!raw-loader!@site/examples/api/quickstart.py';
import quickstartMjs from '!!raw-loader!@site/examples/api/quickstart.mjs';
import quickstartSh from '!!raw-loader!@site/examples/api/quickstart.sh';
import quickstartGo from '!!raw-loader!@site/examples/api/quickstart.go';

## Clients

<ClientsGrid />

## Start the API Server

<Tabs>
<TabItem value="pip" label="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)

</TabItem>
<TabItem value="docker" label="Docker (Full Experience)">

```bash

export OPENAI_API_KEY=sk-xxx

docker run -it --pull always --name hindsight --restart unless-stopped --shm-size=1g -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

:::tip Set a stable `HINDSIGHT_API_WORKER_ID` in production
The worker uses the container hostname as its identity, which Docker sets to the container ID by default. That value changes on every restart, so any task that was being processed when the container went down stays parked under the old ID with no way for the new container to recognize it as its own.

Set `HINDSIGHT_API_WORKER_ID` to a stable value (e.g., `-e HINDSIGHT_API_WORKER_ID=hindsight-prod`) so the worker keeps the same identity across restarts. This is recommended even for single-container deployments. For diagnosis and recovery commands, see [Admin CLI - Recovering stuck operations](/developer/admin-cli#recovering-stuck-or-zombie-operations).
:::

</TabItem>
</Tabs>

:::tip 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

<Tabs>
<TabItem value="python" label="Python">

```bash
pip install hindsight-client
```

<CodeSnippet code={quickstartPy} section="quickstart-full" language="python" />

</TabItem>
<TabItem value="node" label="Node.js">

```bash
npm install @vectorize-io/hindsight-client
```

<CodeSnippet code={quickstartMjs} section="quickstart-full" language="javascript" />

</TabItem>
<TabItem value="cli" label="CLI">

```bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```

<CodeSnippet code={quickstartSh} section="quickstart-full" language="bash" />

</TabItem>
<TabItem value="go" label="Go">

```bash
go get github.com/vectorize-io/hindsight/hindsight-clients/go
```

<CodeSnippet code={quickstartGo} section="quickstart-full" language="go" />

</TabItem>
</Tabs>

---

## 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 |

---

## Integrations

Browse all supported integrations in the [Integrations Hub](/integrations).

## 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

---

### Hindsight Docs Docs Developer Memory Defense Index (`hindsight-docs/docs/developer/memory-defense/index.md`)

---
sidebar_position: 95
---

# Memory Defense

Hindsight scrubs secrets and PII from retain content using a 45-pattern regex set. Each match is replaced with a `[REDACTED:type]` marker before content reaches memory units or the document body. The feature is configured per bank and disabled by default.

## How it works

Memory Defense is opt-in per bank. The extension is always present, but it sits dormant until you give a bank a policy that turns it on. When a policy is set, every memory the agent writes to that bank is scanned before it lands in storage. When the scanner recognizes a credential, an API key, a database connection string, or a known PII format, the matched substring is replaced with a redaction marker like `[REDACTED:github_token]`.

The scrubbed version is what actually gets stored. Memory units and document bodies persist the redacted text, so future recall responses, exports, and reflect operations never see the original secret.

A policy only affects future retain calls on the bank where it is set. Existing memories are not retroactively scanned when you add or change a policy.

## Configuring Memory Defense

Memory Defense is configured per bank via the bank's `memory_defense` config field. You can set the policy at bank creation time or update it later via `PATCH /v1/{tenant}/banks/{bank_id}/config`.

The open-source version implements the `sensitive_data` rule with two possible actions:

- **`redact`** — replace each matched secret with a `[REDACTED:type]` marker and store the scrubbed memory.
- **`block`** — drop any item that contains a match. If every item in a retain request is blocked, the call returns `422`.

A minimal policy:

```json
{
  "memory_defense": {
    "enabled": true,
    "rules": [
      { "on": "sensitive_data", "action": "redact" }
    ]
  }
}
```

Once that policy is on a bank, every retain to that bank is screened with the 45 redaction patterns documented below.

:::note Existing memories are not retroactively scanned
Enabling Memory Defense on a bank only affects future retain calls. Memories already in the bank are not re-scanned or modified when you add or change a policy. If you need to scrub a bank that already contains unredacted content, you have to re-ingest the affected memories or remove them manually.
:::

### Disabled by default

Memory Defense is off on every bank until you set a policy. A bank with no `memory_defense` field, with `enabled: false`, or with no `sensitive_data` rule is treated identically: the extension returns ALLOW and content passes through unchanged. To stop redacting on a bank that has it on, set `enabled: false` or remove the policy.

## Notifications

When an item is redacted or blocked, Hindsight fires a [`memory_defense.triggered` webhook](../api/webhooks.mdx#memory_defensetriggered) if a webhook on the bank is subscribed to that event type. The payload reports the action taken, the document ID, and which redaction patterns matched — useful for routing security alerts to a SIEM or Slack. Clean items fire no event.

The same redact/block decisions are also recorded as `memory_defense` entries in the [audit log](../configuration.mdx#audit-logging) (when audit logging is enabled), with the action and matched pattern labels in the entry metadata.

## Patterns covered

The 45 bundled patterns cover the categories below.

### AI and LLM providers

| Label | Catches |
|---|---|
| `anthropic_key` | `sk-ant-...` |
| `openai_key`, `openai_project_key`, `openai_admin_key` | `sk-...`, `sk-proj-...`, `sk-admin-...` |
| `google_api_key` | `AIza...` (39 chars) |
| `google_oauth_token` | `ya29.<token>` |
| `xai_key` | `xai-...` |
| `groq_key` | `gsk_...` |
| `huggingface_token` | `hf_...` |
| `replicate_token` | `r8_...` |
| `perplexity_key` | `pplx-...` |
| `databricks_token` | `dapi<hex32>` |

### Cloud providers

| Label | Catches |
|---|---|
| `aws_access_key` | `AKIA<16>` |
| `aws_session_token` | `ASIA<16>` |
| `digitalocean_token` | `dop_v1_<hex64>` |

### Source control and CI

| Label | Catches |
|---|---|
| `github_fg_pat` | `github_pat_...` |
| `github_token` | `ghp_<36>` |
| `github_app_token` | `ghs_<36>` |
| `github_user_token` | `ghu_<36>` |
| `github_refresh` | `ghr_<36>` |
| `github_oauth` | `gho_<36>` |
| `gitlab_pat` | `glpat-...` |
| `npm_token` | `npm_...` |
| `pypi_token` | `pypi-AgEIcHlwaS5vcmc...` |

### Payment processors

| Label | Catches |
|---|---|
| `stripe_secret` | `sk_live_...`, `sk_test_...` |
| `stripe_restricted` | `rk_live_...`, `rk_test_...` |
| `square_token` | `sq0...` |
| `braintree_token` | `access_token$production$...` |

### Communications and email

| Label | Catches |
|---|---|
| `slack_token` | `xoxb-`, `xoxp-`, `xoxa-`, `xoxr-` |
| `slack_webhook` | `https://hooks.slack.com/services/...` |
| `twilio_api_key` | `SK<hex32>` |
| `twilio_account_sid` | `AC<hex32>` |
| `sendgrid_key` | `SG.<22>.<43>` |
| `mailgun_key` | `key-<32>` |
| `discord_bot` | `<MNO><23>.<6>.<27>` |
| `telegram_bot` | `<8-10 digits>:<35>` |

### Commerce

| Label | Catches |
|---|---|
| `shopify_token` | `shpat_<hex32>` |

### Database connection strings

| Label | Catches |
|---|---|
| `db_url_postgres` | `postgres://user:pass@host` or `postgresql://...` |
| `db_url_mysql` | `mysql://user:pass@host` |
| `db_url_mongodb` | `mongodb://user:pass@host` or `mongodb+srv://...` |

### Private keys, JWTs, and generic credentials

| Label | Catches |
|---|---|
| `private_key_pem` | `-----BEGIN ... PRIVATE KEY-----` PEM blocks |
| `jwt` | `eyJ<header>.eyJ<payload>.<signature>` |

### PII (US defaults)

| Label | Catches |
|---|---|
| `credit_card` | 13 to 19 digits with regular separators |
| `ssn_us` | `123-45-6789` shape |

---

### Hindsight Docs Docs Cloud Enterprise Enterprise (`hindsight-docs/docs/cloud-enterprise/enterprise.md`)

---
title: Hindsight Enterprise
description: Hindsight Enterprise is a single-tenant Hindsight deployment, in our cloud, your cloud or your data center, with Hindsight Memlake for memory at scale.
---

# Hindsight Enterprise

Hindsight Enterprise is Hindsight for organizations that need their own deployment. Instead of sharing the multi-tenant Hindsight Cloud, you get a **single-tenant** installation that Vectorize sets up and runs with you. It includes everything in Hindsight Cloud, plus the security, compliance, networking and scale features below.

**[Book a call](https://calendly.com/d/dvy5-v3f-jhg?utm_source=oss_docs&utm_campaign=enterprise_docs)** · **[Hindsight Enterprise docs](https://docs.hindsight.vectorize.io/enterprise)**

## Deployment options

| Option | Where it runs | Who runs it |
|---|---|---|
| **Hindsight Cloud with Enterprise features** | Hindsight Cloud (multi-tenant) | Vectorize |
| **Private cloud** | A dedicated deployment in Vectorize's cloud account, in the region you choose | Vectorize |
| **Bring your own cloud (BYOC)** | Your AWS, Google Cloud or Azure account | Vectorize, through access you grant |
| **On-premises** | Your Kubernetes infrastructure, including air-gapped | You, with Vectorize's help |

Private cloud and BYOC can be reached over private networking (AWS PrivateLink, Google Cloud Private Service Connect, Azure Private Link), so traffic never crosses the public internet. See [Choosing a deployment option](https://docs.hindsight.vectorize.io/enterprise/deployment/overview) for a full comparison, including who is responsible for what.

## What Enterprise adds

- **Single-tenant deployment:** its own database, storage, compute and identity service.
- **[Hindsight Memlake](#hindsight-memlake):** memory on object storage, for large and growing banks.
- **Single sign-on and MFA:** Okta, Microsoft Entra ID, Google Workspace or any OIDC or SAML provider.
- **Memory Defense:** detect and redact secrets and sensitive data, and block prompt injection before it is stored.
- **Data privacy (GDPR):** erase, retain and export a person's data across banks.
- **Audit logs, webhooks and SIEM:** a record of who did what, streamed to your own systems.
- **Enterprise support:** a ticket portal, a shared Slack channel and contracted response times.
- **Invoiced billing:** annual or contract billing instead of card payments.

## Hindsight Memlake

Hindsight Memlake is the Enterprise storage engine for agent memory. Open-source Hindsight stores memories in PostgreSQL, which works well for most banks. But agent memory only grows, and as banks reach hundreds of thousands of memories, a relational database gets slower and more expensive to keep fast.

Memlake keeps memories in **object storage** (S3, Google Cloud Storage, Azure Blob Storage, or an S3-compatible store on-premises), with one bucket per organization:

- **Built for scale.** In our tests, 100 million memories were served at under 100 ms p99 recall, on four servers.
- **Fast and steady.** Recall time barely moves as a bank grows, because each bank has its own index, kept warm in a cache on the servers.
- **Lower cost as you grow.** Memories live in object storage, the cheapest durable storage there is, and servers hold only a cache, so you add servers for traffic, not for data.
- **Durable.** A write is in object storage before it is acknowledged, and continuously backed up to a separate bucket.
- **Nothing changes for your agents.** Same API, SDKs and features; banks, settings and background jobs stay in PostgreSQL.

If your banks stay under roughly 50,000 memories each, PostgreSQL is a good fit and Memlake is not needed. Memlake is available on private cloud, BYOC and on-premises deployments.

Read more: [Hindsight Memlake overview](https://docs.hindsight.vectorize.io/enterprise/memlake), [architecture](https://docs.hindsight.vectorize.io/enterprise/memlake/architecture), [performance and cost](https://docs.hindsight.vectorize.io/enterprise/memlake/performance) and [tradeoffs](https://docs.hindsight.vectorize.io/enterprise/memlake/tradeoffs).

## Learn more

- [Hindsight Enterprise documentation](https://docs.hindsight.vectorize.io/enterprise)
- [Ways to run Hindsight](./overview.md): self-hosted, Cloud and Enterprise compared
- [Book a call](https://calendly.com/d/dvy5-v3f-jhg?utm_source=oss_docs&utm_campaign=enterprise_docs) with the Hindsight team

---

### Hindsight Docs Docs Cloud Enterprise Hindsight Cloud (`hindsight-docs/docs/cloud-enterprise/hindsight-cloud.md`)

---
title: Hindsight Cloud
description: Hindsight Cloud is the managed, self-serve Hindsight service. Nothing to deploy, models included.
---

# Hindsight Cloud

Hindsight Cloud is Hindsight run for you. Sign up, create a memory bank and connect your agents, with nothing to deploy or operate. It runs the same engine as open-source Hindsight, so everything in these docs about how memory works applies.

**[Start on Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)** · **[Hindsight Cloud docs](https://docs.hindsight.vectorize.io/cloud)**

## What you get

- **Nothing to run.** No servers, databases or upgrades. The LLM, embedding and reranking models are included.
- **The same API.** Retain, recall, reflect, mental models and knowledge pages, through the same [Python](/sdks/python), [TypeScript](/sdks/nodejs) and [Go](/sdks/go) clients, the [CLI](/sdks/cli) and the MCP server. Point them at your Cloud API URL and key.
- **[Integrations](/integrations).** Pre-built integrations with agent frameworks, coding agents and AI tools.
- **A console.** Browse banks, memories, mental models and knowledge pages, and see how a bank is learning.
- **Teams.** Invite your team with role-based access, and manage API keys.
- **Usage analytics.** Dashboards for what each bank uses.

## Pricing

You pay for what you use, from a prepaid credit balance: per token for retain and recall, per call for reflect and for creating or refreshing mental models and knowledge pages, and for storage of memories kept longer than 30 days. See [Billing](https://docs.hindsight.vectorize.io/cloud/billing) for current rates.

## Enterprise features on Cloud

Some features are part of Hindsight Enterprise and can be turned on for a Cloud organization: SSO, enforced MFA, Memory Defense, data privacy (GDPR), audit logs, webhooks and SIEM, and Enterprise support. See [Hindsight Cloud with Enterprise features](https://docs.hindsight.vectorize.io/enterprise/deployment/cloud-enterprise).

Hindsight Cloud is shared and multi-tenant. If you need a dedicated deployment, see [Hindsight Enterprise](./enterprise.md).

## Learn more

- [Getting started](https://docs.hindsight.vectorize.io/cloud/getting-started): your first memory bank, step by step
- [Hindsight Cloud documentation](https://docs.hindsight.vectorize.io/cloud)
- [Ways to run Hindsight](./overview.md): self-hosted, Cloud and Enterprise compared

---

### Hindsight Docs Docs Developer Admin Cli (`hindsight-docs/docs/developer/admin-cli.md`)

# 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 — installing it puts the `hindsight-admin` executable on your `PATH`:

```bash
pip install hindsight-api
# or
uv add hindsight-api
```

## Running the CLI

`hindsight-admin` connects **directly to PostgreSQL** — it does not call the HTTP API. It reads the **same configuration as the API service** (environment variables, and a `.env` file in the current working directory), so it operates on whatever database `HINDSIGHT_API_DATABASE_URL` points to:

- **Default**: `pg0`, the embedded development database (must be run on the host that owns the pg0 data).
- **Production**: set `HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/hindsight`.

Because it talks to the database directly (binary `COPY`, `TRUNCATE`, etc.), the admin CLI is **PostgreSQL-only** (not supported on Oracle). Run it on the same host/container as your API deployment so it inherits the right configuration and has network access to the database:

```bash
# Bare metal / virtualenv (with the API's env or a .env in the working dir)
hindsight-admin worker-status

# Docker — exec into the API container
docker exec -it hindsight-api hindsight-admin backup /data/backup.zip

# Kubernetes — exec into an API pod
kubectl exec deploy/hindsight-api -- hindsight-admin run-db-migration
```

Use `--schema` to target a specific tenant schema (commands default to the configured base schema). See [Environment Variables](#environment-variables) below.

## Commands

### run-db-migration

Run database migrations to the latest version. By default this migrates the base schema plus all tenant schemas discovered by the tenant extension. Use `--schema` for targeted migration of one schema. 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. If omitted, migrate the base schema plus all discovered tenant schemas. | All schemas |
| `--embedding-dimension` | Expected embedding dimension to enforce after migrations. Omit to skip the post-migration dimension sync. | Skipped |
| `--skip-extension-reconcile` | Skip the post-migration vector / text-search index reconcile (it only does work when `HINDSIGHT_API_VECTOR_EXTENSION` / `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` differs from a schema's existing indexes). Makes a no-change re-migration across many tenant schemas much faster; only use when the backend is unchanged. | Reconcile runs |

**Examples:**

```bash
# Run migrations on the base schema plus all discovered tenant schemas
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.
:::

---

### repair-bank

Verify and repair a bank's derived state: its per-`(bank, fact_type)` vector index coverage, and observations whose source memories no longer exist.

These partial indexes are normally created when a bank is first created (instant on an empty bank), and PostgreSQL maintains them incrementally as the bank grows. A bank that becomes **populated outside that create-time path** — via a logical restore, a cross-version upgrade, or a vector-extension switch — never gets them, so its bank-scoped recall silently falls back to a global index + post-filter. That fallback is both **slower** and can **under-return** results (the approximate nearest-neighbour search draws its candidates from every bank, then filters to yours afterward).

Run this after any of those events to restore full coverage. It detects **missing or invalid** coverage (an INVALID leftover from an interrupted build, or an index whose type drifted after a backend switch, both count as missing) and rebuilds it with `CREATE INDEX CONCURRENTLY`, so it never blocks live retain/recall/consolidation. It is idempotent and safe to re-run.

```bash
hindsight-admin repair-bank (--bank BANK_ID | --all) [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--bank`, `-b` | Bank id to repair. Mutually exclusive with `--all`. | — |
| `--all` | Repair every bank in the base schema and all discovered tenant schemas. | — |
| `--schema`, `-s` | Limit to a single schema. | All schemas |
| `--dry-run` | Report what would be repaired without creating, dropping or deleting anything. | Off |

Exactly one of `--bank` or `--all` is required. Index repair is skipped for backends that use a single global vector index (AlloyDB ScaNN); the observation cleanup below still runs. PostgreSQL only. It is idempotent — safe to re-run and safe to run while the API is serving traffic.

It is also a no-op for a bank whose memories a custom store owns, this time decided
per bank rather than per deployment: such a bank has no rows in `memory_units`, so it
is owed no per-bank index and nothing is built for it. Nothing is dropped either — a
bank that carries indexes from before it moved to the store keeps them, because
shedding them is an operator decision with its own timing, not something a repair
does on your behalf. A store-owned bank therefore reports `0 present, 0 created`
whether or not it still carries indexes; to see what one actually carries, query
the catalog for that bank's own partial indexes:

```sql
SELECT indexname FROM pg_indexes
WHERE schemaname = '<tenant schema>'
  AND tablename = 'memory_units'
  AND indexname LIKE 'idx_mu_emb_%'
  AND strpos(indexdef, $$bank_id = '<bank id>'$$) > 0;
```

Drop the last condition to list every per-bank index in the schema instead.

Those indexes are empty, and Postgres plans against every index on a relation, so on
a deployment with many store-owned banks they are charged to every other query that
touches `memory_units`. Drop them with `DROP INDEX CONCURRENTLY` when you are ready.

**Orphan observations.** An observation summarises the facts it was built from, so when one of those facts is deleted the observation is deleted too, and its other source facts are queued for consolidation again. Older releases missed this on some delete paths, leaving observations behind that cite facts that no longer exist. They keep showing up in recall and reflect, and `export-bank` skips them. `repair-bank` removes them using the same rule a normal delete uses. The surviving facts are consolidated again on the bank's next consolidation run. Use `--dry-run` to only count them.

**Observation keyword index (VectorChord only).** With `HINDSIGHT_API_TEXT_SEARCH_EXTENSION=vchord`, older releases did not update an observation's keyword index when consolidation rewrote or merged its text, so keyword recall kept matching the old wording. `repair-bank` rebuilds the keyword index of every observation in the bank from its current text. A stale one cannot be told apart from an up-to-date one, so all of them are rebuilt. Other text-search backends have nothing to rebuild here. Use `--dry-run` to only count them.

**Exit code.** `0` when every schema was reconciled. `1` when an index failed to build
(the names are printed, and a re-run retries them), or when a schema was not fully
reconciled — for example because the memories store could not say which banks it
owns. The command does not guess there, because guessing would rebuild the very
indexes described above.

A schema is named as skipped whether it failed before its first bank or partway
through, so it may already have had some banks reconciled; when it got that far the
per-schema line says how many, and those builds are not undone. Both the failed-index names and the
skipped schemas are reported before the command exits, so one never hides the other.
If the database connection itself is lost mid-sweep, the run stops there rather than
repeating the same error for every remaining schema, and still reports what it did.

**Examples:**

```bash
# See which banks are missing coverage without changing anything
hindsight-admin repair-bank --all --dry-run

# Repair every bank across all schemas (run once after a restore/upgrade)
hindsight-admin repair-bank --all

# Repair a single bank
hindsight-admin repair-bank --bank acme-prod
```

---

### rename-bank

:::tip Consider an alias instead

A rename requires stopping the bank's clients, because the old id stops working the moment it commits. If what you want is to move callers onto a new id, **[bank aliases](./api/memory-banks.mdx#aliases)** do that with no downtime and no row rewrite: the bank answers to both ids until you are done.

Reach for `rename-bank` when the old id must genuinely stop existing — an alias never removes one.

:::

Change a bank's id in place. Every memory, document, entity, mental model and knowledge page is kept exactly as it is. Nothing is re-extracted or re-embedded, and no LLM calls are made.

```bash
hindsight-admin rename-bank --from OLD_ID --to NEW_ID [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--from` | Current bank id. | — |
| `--to` | New bank id. Must not already exist. | — |
| `--schema`, `-s` | Schema the bank lives in. | Configured base schema |
| `--dry-run` | Run the whole rename, print the rows that would move per table, then roll back. | Off |

**Before you run it:**

- **Stop the bank's clients.** Anything still calling the old id gets a 404, or, if it creates banks on demand, silently starts a new, empty bank under the old id. Point every client, and any API key scoped to the bank, at the new id before restarting them.
- **Let the bank's operations finish.** The command refuses while the bank has pending or processing operations.

A file's storage key spells the bank id, so after the rows move the bank's stored files — document attachments and uploaded originals — are copied to keys under the new id, their rows repointed, and the old id's prefix cleared. That last step also removes any export or import archive left under the old id: their download links stop working at the rename, and the operation can produce them again. Deleting the bank afterwards removes its files with it.

The rename runs in a single transaction and only locks that bank's rows. Other banks keep serving. Its duration grows with the bank's size. Afterwards, the bank's vector indexes are rebuilt with `CREATE INDEX CONCURRENTLY`, and until that finishes, recall on the renamed bank runs without them. Running API servers may keep answering for the old id until their bank-info cache expires. PostgreSQL only.

**Examples:**

```bash
# See what would move, change nothing
hindsight-admin rename-bank --from acme --to acme-prod --dry-run

hindsight-admin rename-bank --from acme --to acme-prod
```

---

### 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
- Mental models and directives
- Webhooks and file storage
- Internal operational tables (async operations, audit log, graph-maintenance queue, and similar bookkeeping) so a restore reproduces a faithful full-database snapshot

:::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` |
| `--yes`, `-y` | Skip confirmation prompt | `false` |

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


### decommission-workers

Release all currently-processing tasks from every worker, resetting them from "processing" back to "pending" status. Use this when one or more workers have crashed or been removed without graceful shutdown and you don't know which worker IDs to target.

```bash
hindsight-admin decommission-workers [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema | `public` |
| `--yes`, `-y` | Skip confirmation prompt | `false` |

**Examples:**

```bash
# Release all processing tasks across all workers (with confirmation)
hindsight-admin decommission-workers

# Skip the confirmation prompt (useful in scripts)
hindsight-admin decommission-workers --yes

# Release tasks in a specific tenant schema
hindsight-admin decommission-workers --schema tenant_acme
```

**When to Use:**

- **Unknown dead workers**: Multiple workers crashed and you do not know their IDs
- **Fleet-wide recovery**: After an infrastructure event where many workers went down
- **"Just fix everything"**: A quick full-queue drain when per-worker cleanup is overkill

:::warning Disruptive
This releases **every** processing task regardless of worker, including tasks owned by healthy workers. Prefer `decommission-worker <WORKER_ID>` when you know which workers need cleanup.
:::

---

### worker-status

Show all currently-processing tasks grouped by worker, including operation type, bank, how long each task has been running, and when it was last updated. Useful for identifying orphaned tasks before decommissioning.

```bash
hindsight-admin worker-status [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--schema`, `-s` | Database schema | `public` |

**Examples:**

```bash
# Show all processing tasks across all workers
hindsight-admin worker-status

# Show processing tasks for a specific tenant schema
hindsight-admin worker-status --schema tenant_acme
```

**When to Use:**

- **Before decommissioning**: Inspect which workers have stale tasks and how long they have been stuck
- **Debugging throughput**: Diagnose why the queue is not draining (are tasks stuck in processing?)
- **Worker health check**: Spot workers whose `last_update_ago` keeps growing, indicating a dead or unresponsive worker

---

### export-bank

Export an entire bank to a portable ZIP archive — documents, facts, observations, bank configuration, mental models, directives, and webhooks. Embeddings are **never** included; they are regenerated on import. This is the source half of a cross-instance migration (e.g. moving to a different embedding model, vector extension, or text-search backend). PostgreSQL only.

```bash
hindsight-admin export-bank --bank <BANK_ID> --output <FILE.zip> [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--bank`, `-b` | Bank id to export. | (required) |
| `--output`, `-o` | Path to write the `.zip` archive. | (required) |
| `--schema`, `-s` | Schema the bank lives in. | base schema |
| `--include-history` | Also export operational history (`audit_log`, `llm_requests`). | `false` |

**Examples:**

```bash
hindsight-admin export-bank --bank my-bank --output my-bank.zip

# include operational history
hindsight-admin export-bank --bank my-bank --output my-bank.zip --include-history
```

Read-only — safe to run against a live instance.

---

### import-bank

Restore a whole-bank archive (produced by `export-bank`) into **this** instance. Facts are re-embedded with this instance's configured embedding model and links/indexes are rebuilt; bank configuration, mental models, directives, and webhooks are restored exactly. No LLM fact-extraction runs, and because a migration restores state, it does **not** fire webhooks or re-run consolidation. PostgreSQL only.

```bash
hindsight-admin import-bank --archive <FILE.zip> [OPTIONS]
```

**Options:**

| Option | Description | Default |
|--------|-------------|---------|
| `--archive`, `-a` | Path to the `.zip` produced by `export-bank`. | (required) |
| `--schema`, `-s` | Target schema. | base schema |
| `--target-bank` | Override the bank id (defaults to the archive's source bank). | source bank |
| `--include-history` | Also restore history if present in the archive. | `false` |

**Examples:**

```bash
hindsight-admin import-bank --archive my-bank.zip
```

Run this against an instance configured with the **target** embedding model / vector extension / text-search backend — that's what re-embedding uses.

:::warning Target bank must not exist
Import restores a **whole bank** (config, facts, mental models, …) — it is **not a merge**. If a bank with the target id already exists, the command fails. Delete that bank first, or use `--target-bank` to restore under a fresh id.
:::

---

## Migrating a bank to a new instance

Changing a bank's **embedding model** (e.g. a 384-dim encoder → a 1024-dim one), **vector extension** (pgvector / vchord / pgvectorscale), or **text-search backend** can't be done in place on a populated bank — the stored vectors and indexes are tied to those settings. Because every embedding and index is a deterministic function of text already on disk, the supported path is to **move the bank to a fresh instance configured with the new settings and re-derive everything there — with no LLM re-extraction**.

`export-bank` / `import-bank` carry documents, facts, observations, bank config, mental models, directives, and webhooks — but never embeddings, which the target instance regenerates with its own model.

**Blue-green runbook:**

1. Stand up a **new instance** on a fresh database, configured with the new embedding model / vector extension / text-search backend.
2. Quiesce writes to the source bank (maintenance window) and run `hindsight-admin backup` for safety.
3. Export from the source, then import into the target:
   ```bash
   # on the source instance:
   hindsight-admin export-bank --bank my-bank --output my-bank.zip
   # on the target instance (configured with the new settings):
   hindsight-admin import-bank --archive my-bank.zip
   ```
4. Verify on the target: run representative recall queries and compare results.
5. Cut traffic over to the new instance. The old instance stays as an instant rollback until you're confident.

:::note Why a new instance, not in-place
The embedding model is server-level, and a bank's `memory_units.embedding` column has a single dimension shared across the schema, so a different-dimension or different-backend bank needs its own instance/database. The old vectors are never mutated, which makes rollback trivial.
:::

---

## Recovering stuck or zombie operations

A "zombie" operation is one stuck in `processing` indefinitely because the worker that claimed it is gone. The most common cause is an unstable `HINDSIGHT_API_WORKER_ID`: when it defaults to the container hostname, a Docker restart produces a new container ID, the new worker doesn't recognize the old worker's claims as its own, and those tasks are stranded.

**How to spot them:**

```bash
# List processing tasks grouped by worker — workers with a growing last_update_ago are dead
hindsight-admin worker-status

# Bank-level counters; pending_consolidation that never decreases is the usual symptom
curl -s http://localhost:8888/v1/default/banks/<bank_id>/stats
```

**How to recover:**

```bash
# You know which worker is dead (e.g. from worker-status):
hindsight-admin decommission-worker <old-worker-id>

# You don't know — release every processing task across the fleet:
hindsight-admin decommission-workers
```

Both commands reset `processing` rows back to `pending` so a live worker can claim them on the next poll.

**How to prevent it:**

Set `HINDSIGHT_API_WORKER_ID` to a stable value so worker identity survives restarts:

- **Docker**: pass `-e HINDSIGHT_API_WORKER_ID=hindsight-prod` (or per-replica names if running multiple containers)
- **Kubernetes (Helm)**: the chart's StatefulSet uses the pod name automatically — no extra config needed
- **Bare metal / pip**: pass `--worker-id <name>` or set the env var per process

See [Installation - Docker](./installation#docker) and [Configuration - Distributed Workers](./configuration#distributed-workers).

---

## 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
```

---

### Hindsight Docs Docs Developer Configuration (`hindsight-docs/docs/developer/configuration.mdx`)

import CodeSnippet from '@site/src/components/CodeSnippet';
import configurationPy from '!!raw-loader!@site/examples/api/configuration.py';
import configurationSh from '!!raw-loader!@site/examples/api/configuration.sh';

# 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_READ_DATABASE_URL` | Optional read-replica PostgreSQL URL. When set, recall queries (semantic, BM25, graph, temporal) are routed through a separate connection pool against this URL, offloading the primary. Typically points to a read-only endpoint (e.g., CNPG's `<cluster>-ro` service or Aurora reader endpoint). | Unset (uses primary) |
| `HINDSIGHT_API_MIGRATION_DATABASE_URL` | Direct PostgreSQL URL for running migrations, bypassing connection poolers (e.g. PgBouncer). When set, advisory locks and Alembic migrations use this URL instead of `DATABASE_URL`. | Falls back to `DATABASE_URL` |
| `HINDSIGHT_API_DATABASE_SCHEMA` | PostgreSQL schema name for tables | `public` |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` |
| `HINDSIGHT_API_MIGRATION_ISOLATION` | Run migrations in a subprocess instead of in the calling process: `true` (keeps Alembic's import graph and its psycopg2 sync engine out of a long-lived server process) or `false` | `false` |
| `HINDSIGHT_API_MIGRATION_CONCURRENCY` | Number of tenant schemas to migrate concurrently (PostgreSQL only). Each schema runs in its own process; within a schema migrations are always sequential. Each worker has a fixed startup cost (~1–2s to boot a fresh interpreter), so this only pays off with **many** schemas (roughly tens or more) or slow/high-latency migrations — for a handful of schemas it is slower than sequential. Each worker uses ~3 database connections, so keep `concurrency × 3` within your database's spare `max_connections` (and any PgBouncer pool limit). `1` = fully sequential. Measured at 20k schemas: the per-restart no-op resweep dropped from ~60min to ~11min (≈5×) at `concurrency=12`. | `1` |
| `HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES` | Comma-separated list of maintenance discovery routines this deployment installs itself (see [Owning a maintenance routine](#owning-a-maintenance-routine)). Migrations skip anything named here. | Empty (every routine installed) |
| `HINDSIGHT_API_DATABASE_BACKEND` | Database engine backend: `postgresql` or `oracle` (Oracle 23ai) | `postgresql` |

If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.

To tune the embedded database, add PostgreSQL settings as a query string: `pg0://hindsight?max_connections=300&shared_buffers=256MB`. They apply when the embedded database starts, so restart it after changing them.

To run against Oracle Database 23ai instead, set `HINDSIGHT_API_DATABASE_BACKEND=oracle` and use an `oracle+oracledb://…` URL. See the [Oracle Database guide](./oracle) for full setup instructions.

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.

#### Owning a maintenance routine

The background maintenance loop finds work by calling four PostgreSQL routines —
`mental_models_with_cron`, `banks_needing_consolidation`, `schemas_with_expired_rows` and
`schemas_with_expired_operations`. The stock implementations scan every schema in the
database on each tick. On a large multi-tenant install that is a serial loop over
thousands of schemas, repeated once per tick in every process running the loop.

Because they are called by name, you can replace one with an implementation suited to
your installation — typically a registry table maintained by a row trigger, so discovery
costs one indexed read instead of a scan. List the ones you have replaced and migrations
will leave them alone:

```bash
export HINDSIGHT_API_EXTERNALLY_OWNED_ROUTINES=mental_models_with_cron,banks_needing_consolidation
```

Read from the environment of whatever process runs the migration, so it applies equally to
`hindsight-admin run-db-migration`, a migration Job, and migrate-on-startup.

Without this, your replacement survives only until the next migration that reinstalls the
routine: they are all `CREATE OR REPLACE` against the same name, so the migration wins and
nothing records that a custom implementation was discarded.

A few things to know:

- **Your replacement must match the stock signature.** The loop calls these with fixed
  arguments and reads fixed columns back.
- **Nothing verifies that a replacement exists.** Naming a routine you have not installed
  leaves whatever was there before — nothing, on a fresh database — and the maintenance
  loop then fails on the missing function rather than quietly running the scan you were
  trying to avoid.
- **Ownership is not stamped into the database.** Remove a name from the list and the next
  migration reinstalls the stock routine, with no data fix-up.
- **Empty by default.** An installation that has not replaced anything is unaffected.

### Database Connection Pool

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DB_POOL_MIN_SIZE` | Minimum connections in the primary pool | `5` |
| `HINDSIGHT_API_DB_POOL_MAX_SIZE` | Maximum connections in the primary pool | `100` |
| `HINDSIGHT_API_READ_DB_POOL_MIN_SIZE` | Minimum connections in the read-replica pool (only used when `READ_DATABASE_URL` is set) | Falls back to `DB_POOL_MIN_SIZE` |
| `HINDSIGHT_API_READ_DB_POOL_MAX_SIZE` | Maximum connections in the read-replica pool (only used when `READ_DATABASE_URL` is set) | Falls back to `DB_POOL_MAX_SIZE` |
| `HINDSIGHT_API_DB_COMMAND_TIMEOUT` | PostgreSQL command timeout in seconds (asyncpg client-side) | `60` |
| `HINDSIGHT_API_DB_ACQUIRE_TIMEOUT` | Connection acquisition timeout in seconds. Bounds how long a caller waits for a free pool connection before failing (retried by the caller); `0` waits indefinitely. | `30` |
| `HINDSIGHT_API_DB_POOL_SLOW_ACQUIRE_THRESHOLD_SECONDS` | Log a `[DB POOL] Slow acquire` warning when getting a pool connection takes longer than this many seconds. Raise it when new connections are slow by nature (e.g. TLS to Aurora Serverless); `0` disables the warning. | `0.05` |
| `HINDSIGHT_API_DB_STATEMENT_TIMEOUT` | Postgres `statement_timeout` applied to every pool connection, in seconds. Server-side safety net for runaway queries. Does **not** apply to Alembic migrations (which run on a separate psycopg2 engine). Set to `0` to disable. | `600` |
| `HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER` | Optional Postgres `max_parallel_workers_per_gather` applied to every pool connection of this process. Unset leaves the server default. Set to `0` on background-worker processes so bulk maintenance queries (consolidation, graph upkeep) run serially instead of fanning out across CPU cores shared with latency-sensitive traffic. | unset |
| `HINDSIGHT_API_DB_SESSION_SETUP_ON_ACQUIRE` | Whether the per-connection session settings above (`statement_timeout`, `max_parallel_workers_per_gather`, the trigram threshold, the vector-search tuning, and — on the `vchord` text-search backend — the search path) are re-applied every time a connection is taken from the pool, not only when it is first opened. Keep this on unless the same settings are already pinned on the database role or the database itself (`ALTER ROLE … SET`), because behind a transaction-mode connection pooler an acquire can be linked to a server connection that never received them (on a direct connection they now survive, so the re-apply is redundant there) — with the re-apply off and nothing pinned server-side, reused connections quietly run without them, and on `vchord` recall fails outright rather than merely degrading. When they *are* pinned server-side the re-apply changes nothing and only costs a round trip per acquire, which is worth reclaiming on busy deployments behind a transaction-mode connection pooler. `application_name` is always re-applied and is unaffected by this setting. | `true` |
| `HINDSIGHT_API_ENTITY_TRGM_SIMILARITY_THRESHOLD` | Postgres `pg_trgm.similarity_threshold` applied to every pool connection, governing how close a name must be for entity resolution's `%` trigram match to treat it as a candidate. Must be between `0` (exclusive) and `1`. Never applied below `HINDSIGHT_API_ENTITY_MERGE_MIN_SIMILARITY`, since a candidate under the merge floor can never be merged. Raise it above that floor to look at fewer, closer names. | `0.3` |
| `HINDSIGHT_API_ENTITY_INTRABATCH_MERGE_SIMILARITY` | Trigram similarity (pg_trgm-equivalent, computed in-memory) at/above which two brand-new names created by the **same** retain are merged into a single entity (in-batch dedup of surface-form variants — e.g. the same name with different emoji/case/suffix). Must be between `0` (exclusive) and `1`. This is a *merge* cutoff, deliberately stricter than the recall-only threshold above; raise it toward `1.0` to merge only near-identical forms. | `0.5` |
| `HINDSIGHT_API_ENTITY_MERGE_MIN_SIMILARITY` | Minimum trigram similarity a name must have with an **existing** entity before that entity can be reused for it, whatever the other resolution signals say. Sits between the recall threshold above (`0.15`, which only decides what is *considered*) and the same-batch fold-in cutoff below (`0.5`). Must be between `0` (exclusive) and `1`. Lower it for corpora of very short names, where trigram similarity is unavoidably low (`Jon`/`John` is `0.29`); raise it to merge only clear surface variants. | `0.3` |

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
# Migrate the base schema plus all discovered tenant schemas
hindsight-admin run-db-migration

# Or migrate a specific schema only:
hindsight-admin run-db-migration --schema tenant_acme
```

### Vector Extension

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_VECTOR_EXTENSION` | Vector index algorithm: `pgvector`, `vchord`, `pgvectorscale`, or `scann` | `pgvector` |
| `HINDSIGHT_API_ANN_ITERATIVE_SCAN` | Let a vector index scan resume until the query's `LIMIT` is satisfied, instead of stopping when its first candidate list drains. With it off, a recall can never retrieve more rows than that list holds — on pgvector, `hnsw.ef_search` (200) — so a larger recall budget widens the SQL and retrieves nothing extra. Requires pgvector 0.8.0+; older servers reject the setting and it is dropped automatically after the first attempt. This is the operational kill switch: setting it to `false` and restarting restores the previous retrieval depth exactly, with no code change. | `true` |
| `HINDSIGHT_API_ANN_MAX_SCAN_TUPLES` | Ceiling on how many tuples a single resumed scan may visit (`hnsw.max_scan_tuples`). This is the knob that governs what iterative scans cost: the filters that thin a result — the similarity floor, tags, date ranges — are applied *after* the index scan, so a selective query resumes repeatedly, and this bounds both the CPU it can spend and the memory it can hold (pgvector otherwise caps the latter at `work_mem × hnsw.scan_mem_multiplier`, which at this default is never approached). Lower it to trade retrieval depth back for latency; the initial scan is not counted, so even `1` leaves the pre-existing depth intact. pgvector's own default is `20000`. Ignored when iterative scans are off. | `4000` |

Hindsight supports four PostgreSQL vector extensions:

#### **pgvector** (HNSW - default)
- In-memory index using Hierarchical Navigable Small World algorithm
- Works well for most embeddings and dataset sizes
- Fast for small-medium datasets (&lt;10M vectors)
- Higher memory usage for large datasets
- Most widely deployed and supported

#### **pgvectorscale** (DiskANN - recommended for scale) ⭐
- Disk-based index using StreamingDiskANN algorithm
- **28x lower p95 latency** and **16x higher throughput** vs dedicated vector DBs
- **60-75% cost reduction** at scale (SSDs cheaper than RAM)
- Superior filtering performance with streaming retrieval model
- Optimized for large datasets (10M+ vectors)
- Supports both **pgvectorscale** (open source) and **pg_diskann** (Azure)
- **Installation:**
  - Open source/self-hosted: `CREATE EXTENSION vector; CREATE EXTENSION vectorscale CASCADE;`
  - Azure PostgreSQL: `CREATE EXTENSION vector; CREATE EXTENSION pg_diskann CASCADE;`

#### **vchord** (vchordrq)
- Alternative high-performance vector index
- Optimized for high-dimensional embeddings (3000+ dimensions)
- Includes integrated BM25 search capabilities
- Requires `vchord` extension

#### **scann** (AlloyDB ScaNN)
- Google's ScaNN index, available on **AlloyDB** and **AlloyDB Omni**
- Uses a single global vector index in `AUTO` mode (per-bank partial indexes are not used)
- **Installation:** `CREATE EXTENSION vector; CREATE EXTENSION alloydb_scann CASCADE;`
- **Index build is deferred** until a table reaches **10,000 populated embedding rows** — AlloyDB cannot build a ScaNN AUTO index on a near-empty table. Until that threshold is crossed, recall falls back to a sequential scan; the global index is built on the next API startup once enough rows exist.
- A ready-to-use Docker Compose stack is provided at [`docker/docker-compose/alloydb/docker-compose.yaml`](https://github.com/vectorize-io/hindsight/blob/main/docker/docker-compose/alloydb/docker-compose.yaml) for running Hindsight against AlloyDB Omni locally.

**When to use pgvectorscale (DiskANN):**
- Large datasets (10M+ vectors) ⭐
- Complex filtering requirements
- Cost-sensitive deployments
- Production workloads requiring high throughput
- When disk I/O is not a bottleneck

**When to use pgvector (HNSW):**
- Small-medium datasets (&lt;10M vectors)
- Maximum query speed when all data fits in memory
- Simple nearest-neighbor queries without filters
- Standard PostgreSQL deployment preference

**When to use vchord:**
- High-dimensional embeddings (3000+ dimensions)
- Want integrated BM25 search
- Already using vchord for text search

**When to use scann:**
- Running on Google **AlloyDB** or **AlloyDB Omni**
- Want managed ScaNN with `AUTO` mode tuning

#### Limiting vector indexes on large deployments

On `pgvector`, `pgvectorscale` and `vchord`, a bank's memories are indexed per
`(bank, fact_type)`. By default every bank gets its own indexes when it is
created, which is the right thing for most deployments and needs no
configuration.

It stops being the right thing when you have thousands of banks. These indexes
all live on one shared table, and PostgreSQL inspects and locks **every** index
on a table whenever it plans a query against it — so an index created for one
bank is a cost paid by searches in every other bank. Past a few thousand banks,
planning slows sharply and the server eventually runs out of lock-table space,
failing reads *and* bank deletion alike.

Setting a minimum size fixes that: a bank only gets its own indexes once it is
large enough to benefit from them. Below the threshold PostgreSQL answers the
same search from the `(bank_id, fact_type)` B-tree plus a top-N sort, which for
a small bank is both faster and *exact* rather than approximate — so small banks
lose nothing. The number of indexes becomes proportional to the number of
**large** banks rather than to the number of banks, and bank count stops being a
limit.

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS` | Memories a bank needs, in one fact type, before that fact type gets its own vector index. `0` (the default) turns the threshold **off**: every bank is indexed from the moment it is created. `10000` is a good starting point for deployments with thousands of banks. A bank whose memories a custom store owns is outside this at any value — it has no rows here, so it is never given a per-bank index, and never has an existing one taken away (see `repair-bank` in the [admin CLI](./admin-cli.md)). | `0` |
| `HINDSIGHT_API_VECTOR_INDEX_MAINTENANCE_MIN_INTERVAL_SECONDS` | Shortest gap between two index-maintenance runs for one bank. Stops a bank whose size hovers at the threshold from building and dropping the same index repeatedly. Unused while the threshold is off. | `900` |

**With the threshold off (the default),** indexes are created inside the
transaction that creates the bank — instantly, because the bank is empty — and
dropped when the bank is deleted. Nothing inspects bank sizes, and no background
operation runs.

**With a threshold set,** bank creation, ingestion and import build no indexes at
all, so no request ever waits on index DDL. Instead, a write that could move a
bank across the threshold queues a background `vector_index_maintenance`
operation, which builds an index when a bank grows past the threshold and removes
it if the bank shrinks well below it (the gap between those two points prevents
churn at the boundary). You will see these operations in the bank's operations
list.

To reconcile without waiting for a write — after a restore, an upgrade, or an
extension switch — run:

```bash
hindsight-admin repair-bank --all
```

This works in both modes: with the threshold off it rebuilds any index a bank is
missing, and with one set it also drops what a bank no longer earns.

**Switching extensions:**

If you need to switch from one extension to another:
1. Set `HINDSIGHT_API_VECTOR_EXTENSION` to your desired extension (`pgvector`, `vchord`, `pgvectorscale`, or `scann`)
2. If your database has existing data, you'll get an error with migration instructions (note: switching **to** `scann` is allowed even with data — the existing index is dropped and rebuilt as ScaNN once the table has at least 10,000 embedding rows)
3. For empty databases, indexes will be automatically recreated on startup

**Learn more:**
- [HNSW vs. DiskANN comparison](https://www.tigerdata.com/learn/hnsw-vs-diskann)
- [pgvectorscale GitHub](https://github.com/timescale/pgvectorscale)

### Text Search Extension

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` | Text search backend: `native`, `vchord`, `pg_textsearch`, `pgroonga`, or `pg_search` | `native` |
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE` | PostgreSQL text search dictionary used by the `native` backend (e.g. `english`, `french`, `simple`, `zhparser`) | `english` |
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` | ParadeDB `pg_search` tokenizer used when creating BM25 indexes. Empty uses ParadeDB's default tokenizer (`unicode_words`). | unset |
| `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_FUNCTION_SCHEMA` | Schema containing ParadeDB `pg_search` functions (`score`, `boolean`, `match`). Default is `paradedb`; set to `pgsearch` (or another valid identifier) on managed PostgreSQL distributions that install functions under a custom schema. | `paradedb` |
| `HINDSIGHT_API_BM25_MAX_QUERY_TERMS` | Cap on the number of terms in the native PostgreSQL BM25 `tsquery`. Long queries OR-join every normalized token, and because native ranking has no IDF and re-ranks every match, an uncapped query over common terms can scan and rank a large fraction of the bank and time out. When the query exceeds the cap, the most **selective** terms are kept — those with the lowest tenant-wide document frequency, read for free from `pg_stats` (autovacuum-maintained, no reindex) — so a blunt truncation doesn't discard the high-signal terms. `0` restores the historical uncapped behavior; the cap bounds only the `native` backend (other BM25 backends receive the raw query and rank via their own index-native operators). | `16` |
| `HINDSIGHT_API_BM25_SELECTIVE_TERMS` | When the cap above trims a query, whether to keep the most **selective** terms (lowest document frequency, from `pg_stats`) rather than the first N. `true` is strictly better for recall at no extra cost when stats exist; set `false` to opt out of the catalog read and cap by position. Ignored when the cap is `0`. | `true` |
| `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` | When set, forces every LLM-generated artifact (retain facts, consolidation observations, reflect responses) into this language. Free-form (e.g. `Spanish`, `Japanese`). | unset |

Hindsight supports five backends for BM25 keyword retrieval:
- **native** — PostgreSQL's built-in full-text search (`tsvector` + GIN). Language configurable.
- **vchord** — VectorChord BM25 (uses the `llmlingua2` multilingual tokenizer).
- **pg_textsearch** — Timescale's pg_textsearch extension. English-only.
- **pgroonga** — pgroonga full-text search. Multilingual / CJK out of the box.
- **pg_search** — ParadeDB pg_search. True BM25; the only backend that is Citus-compatible.

A bank can also use none of them: [`enable_text_search`](#recall-pipeline-stages)
switches the keyword arm off per bank, leaving pure vector search. Every setting in
this table is then unused by that bank.

To switch backends: set `HINDSIGHT_API_TEXT_SEARCH_EXTENSION`. With existing data, you'll get an error and migration instructions; with an empty database the columns/indexes are recreated automatically on startup.

`HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` only applies when `HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pg_search`, and only when BM25 indexes are created. Changing it for an existing database requires rebuilding the `pg_search` indexes or recreating the database. Supported values are empty/unset, `unicode_words`, `simple`, `whitespace`, `literal`, `literal_normalized`, `chinese_compatible`, `icu`, `jieba`, `source_code`, `chinese_lindera`/`lindera(chinese)`, `japanese_lindera`/`lindera(japanese)`, `korean_lindera`/`lindera(korean)`, `ngram(min,max)`, and `edge_ngram(min,max)`.

For non-English banks (especially CJK) and the language/extraction-language tradeoffs, see the [Multilingual Support](./multilingual) page.

### LLM Provider

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-responses`, `openai-codex`, `chatgpt`, `claude-code`, `cursor`, `github-copilot`, `anthropic`, `gemini`, `groq`, `minimax`, `deepseek`, `zai`, `opencode-go`, `meta`, `nous`, `xai-oauth`, `fireworks`, `ollama`, `ollama-cloud`, `lmstudio`, `llamacpp`, `vertexai`, `bedrock`, `litellm`, `litellmrouter`, `volcano`, `openrouter`, `requesty`, `none` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for providers that require one; unused by `github-copilot`. Optional for `anthropic` when [Workload Identity Federation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) is configured (see the Anthropic example below) | - |
| `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. When the cap is full, live API requests get the next free slot ahead of background jobs (async retain, consolidation) run by the in-process worker. | `32` |
| `HINDSIGHT_API_LLM_MAX_RETRIES` | Max retry attempts for LLM API calls | `3` |
| `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. Honoured by every provider as a **total** deadline for one request, including the time spent reading a streamed response — a backend that keeps sending bytes is cut off at the deadline rather than read indefinitely. Raise it if your provider is legitimately slow; the per-operation variables below override it. | `120` |
| `HINDSIGHT_API_LLM_CONNECT_TIMEOUT` | Ceiling on the **connect** phase (TCP + TLS handshake) of an LLM request, in seconds. The read, write and pool phases keep the full `HINDSIGHT_API_LLM_TIMEOUT` budget; capping connect separately means an unreachable or wedged endpoint fails in seconds instead of consuming the whole request timeout. Effective value is the smaller of this and the request timeout. Set to `0` to disable the cap and use one value across all four phases. Applies to every provider whose HTTP client Hindsight constructs (all OpenAI-compatible backends, `openai-responses`, `chatgpt`, `anthropic`, `openai-codex`, `xai-oauth`, `fireworks`, `nous`, `llamacpp`); `litellm`/`litellmrouter` and `gemini`/`vertexai` own their transports and take a single total deadline instead, and `claude-code`/`cursor`/`github-copilot` drive a CLI rather than a socket. | `10` |
| `HINDSIGHT_API_LLM_HTTP_LOG_LEVEL` | Log level for the `httpx` and `httpcore` loggers. Set to `DEBUG` to trace each LLM request through its transport phases (`connect_tcp`, `send_request_headers`, `receive_response_headers`) — the way to tell a request that stalled before it was ever sent from one that was sent and never answered. Verbose; for diagnosis only. | `WARNING` |
| `HINDSIGHT_API_LLM_REASONING_EFFORT` | Reasoning effort for providers/models that support it (for example `none`, `low`, `medium`, `high`, `xhigh`). Set it and the value is sent as given, whatever your model is called — which is how you control thinking-token volume on a self-hosted reasoning model (vLLM, Ollama, llama.cpp, TGI), where `none` is often the only value that removes the thinking block. Leave it unset and no reasoning parameter is sent at all, so each model runs at its own default effort. Honoured by `openai` and every OpenAI-compatible provider, `openai-responses`, `chatgpt`, `openai-codex`, `xai`, `llamacpp`, and `litellm`/`litellmrouter` (which translate it per target provider). The native `gemini`/`vertexai`, `anthropic`, `claude-code` and `cursor` providers have no reasoning-effort control and log a warning at startup if you set one — reach those models through `litellm` to apply it. | Unset (model's own default) |
| `HINDSIGHT_API_LLM_TEMPERATURE` | Global override for the sampling temperature of internal LLM calls. Set a number in `[0.0, 2.0]`, or `none` (also `default`/`off`/empty) to **omit** the temperature parameter entirely — required for models that reject explicit temperatures, e.g. Azure `gpt-5.5`, which only accepts its default value. Per-operation variables below override this. | Per-operation defaults |
| `HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION` | Temperature for the startup connection check. Number in `[0.0, 2.0]` or `none` to omit. Overrides `HINDSIGHT_API_LLM_TEMPERATURE`. | `0.0` |
| `HINDSIGHT_API_LLM_TEMPERATURE_RETAIN` | Temperature for fact extraction during retain. Number in `[0.0, 2.0]` or `none` to omit. Overrides `HINDSIGHT_API_LLM_TEMPERATURE`. | `0.1` |
| `HINDSIGHT_API_LLM_TEMPERATURE_REFLECT` | Temperature for the reflect "thinking" step. Number in `[0.0, 2.0]` or `none` to omit. Overrides `HINDSIGHT_API_LLM_TEMPERATURE`. | `0.9` |
| `HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION` | Temperature for consolidation (dedup), and for the mental-model refresh's delta and retraction calls unless `HINDSIGHT_API_LLM_TEMPERATURE_MENTAL_MODEL_REFRESH` is set. Number in `[0.0, 2.0]` or `none` to omit. Overrides `HINDSIGHT_API_LLM_TEMPERATURE`. | `0.0` |
| `HINDSIGHT_API_LLM_TEMPERATURE_MENTAL_MODEL_REFRESH` | Temperature for the mental-model refresh's structured edit calls (delta and retraction). Number in `[0.0, 2.0]` or `none` to omit. Set it when the refresh runs its own model (`HINDSIGHT_API_MENTAL_MODEL_REFRESH_LLM_*`) whose recommended temperature differs from consolidation's. | Falls back to `HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION` |
| `HINDSIGHT_API_LLM_VISION` | Whether the configured LLM can read images, overriding what the provider reports about itself. Leave unset and each provider answers for itself: Anthropic and Gemini report yes, OpenAI reports yes, `none` reports no, and every gateway-style backend (LiteLLM, Ollama, LM Studio, OpenRouter, an OpenAI-compatible proxy) reports *unknown*, because its catalogue mixes vision-capable and text-only models. A retain carrying [inline attachments](#inline-attachments-in-retain) is refused with `422` on both "no" and "unknown" — dropping an attachment silently would leave a document that looks retained with the information the caller cared about gone. Set `true` when you are running a vision model behind such a gateway; set `false` to refuse images against an endpoint that rejects them despite its model name. | Provider decides |
| `HINDSIGHT_API_VLM_PROVIDER` | Provider for the **vision slot** — the model used only for retain chunks that carry an [inline attachment](#inline-attachments-in-retain). Every text-only chunk keeps using the retain LLM, so a bank whose documents mostly *are* text does not pay a vision model's price for all of it. Unset, attachments go to the retain LLM. | Retain LLM |
| `HINDSIGHT_API_VLM_API_KEY` | API key for the vision slot. | Retain LLM's |
| `HINDSIGHT_API_VLM_MODEL` | Model for the vision slot. This is the model `HINDSIGHT_API_LLM_VISION` and the `422` vision check apply to, since it is the one that will actually be handed the attachment. | Retain LLM's |
| `HINDSIGHT_API_VLM_BASE_URL` | Base URL for the vision slot. Follows `HINDSIGHT_API_VLM_PROVIDER`'s own default when that is set and this is not — it never inherits the retain provider's host, which would send the request to the wrong endpoint with the wrong key. | Provider default |
| `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` | Tag outbound LLM and embedding calls with `user=<bank_id>` so gateways (OpenRouter usage accounting, LiteLLM, Helicone) can attribute spend per bank. When enabled, the bank id is transmitted to the upstream provider as the end-user identifier. | `false` |
| `HINDSIGHT_API_LLM_GROQ_SERVICE_TIER` | Groq service tier: `on_demand`, `flex`, `auto` | `auto` |
| `HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER` | OpenAI service tier: `flex` for 50% cost savings (OpenAI Flex Processing) | None (default) |
| `HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER` | Bedrock service tier: `flex` for 50% cost savings (best-effort inference), `priority` (guaranteed throughput), or `reserved` (provisioned capacity) | Unset (default tier) |
| `HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER` | Gemini service tier: `flex` for 50% cost savings (best-effort inference) | Unset (default tier) |
| `HINDSIGHT_API_LLM_EXTRA_BODY` | JSON dict of extra request-body params (e.g. `temperature`, `top_p`, `max_tokens`) merged into every LLM call. Applied across the OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI and LiteLLM (incl. Bedrock/Router) providers. Each provider merges them in its own native parameter space, so use that provider's field names (e.g. `max_tokens` for OpenAI/Anthropic vs `max_output_tokens` for Gemini). The native Ollama structured-output path is a special case — see the note below. Also useful for custom model servers (e.g. vLLM `chat_template_kwargs`). | `null` |
| `HINDSIGHT_API_LLM_DEFAULT_HEADERS` | JSON dict passed as `default_headers` to provider SDK clients. Used by operators routing through proxies / request-tracing middleware (e.g. Cloudflare AI Gateway, Helicone, corporate proxies). Wired into the Anthropic provider, the OpenAI-compatible providers, Fireworks, Nous, the OpenAI Responses API, and the LiteLLM-backed providers (`litellm`, `litellmrouter`, `bedrock`); other providers can opt in. | `null` |
| `HINDSIGHT_API_LLM_CACHE_AFFINITY` | Pin a conversation to one backend prompt cache on the OpenAI-compatible providers, Fireworks and Nous. Server-side prompt caches are per backend server, so the same conversation has to reach the same one for a cache hit. `xai_conv_id` sends xAI's documented `x-grok-conv-id` header; `openai_prompt_cache_key` sends OpenAI's `prompt_cache_key` field; `auto` picks one from the configured base URL host (x.ai / grok.com &rarr; `xai_conv_id`; native OpenAI or an openai.com host &rarr; `openai_prompt_cache_key`; anything else, including Azure OpenAI, &rarr; `none`); `none` sends nothing. The id is the operation's trace id, so every LLM call of one retain/reflect/consolidation run shares it. `auto` is an allowlist rather than a best-effort probe, so an unknown OpenAI-compatible backend (vLLM, ollama, groq, openrouter, a custom proxy) receives byte-identical requests to before; set `none` to disable entirely. Anthropic and Gemini/Vertex do their own caching and ignore this. | `auto` |
| `HINDSIGHT_API_LLM_STRICT_SCHEMA` | Grammar-enforce structured output via `json_schema` `strict: true` instead of the soft "schema-in-prompt + `json_object`" path. Typed Pydantic response models are serialized directly into the OpenAI strict subset: every object rejects additional properties, every declared property is required, and nullable fields remain nullable. Use it with weaker self-hosted models that return prose preambles, markdown ` ```
/* Detailed source-code truncated for AI context efficiency. */
```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
# Optional: Use Flex Processing for 50% cost savings (with variable latency)
# export HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER=flex

# OpenAI Responses API (/v1/responses)
export HINDSIGHT_API_LLM_PROVIDER=openai-responses
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-5.6
# Uses the OpenAI Responses API instead of chat/completions. Unlike
# chat/completions, it supports reasoning together with function tools, so
# reflect's tool-calling search loop can run with a real reasoning effort.
# Recommended for reasoning models (gpt-5.x, o-series) that reject
# `reasoning_effort` alongside tools on chat/completions (e.g. gpt-5.6-terra).
# export HINDSIGHT_API_LLM_REASONING_EFFORT=high

# Gemini
export HINDSIGHT_API_LLM_PROVIDER=gemini
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
# Optional: Use Gemini Flex for 50% cost savings (best-effort inference)
# export HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER=flex

# 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
# Or, with no static key: Workload Identity Federation. Leave HINDSIGHT_API_LLM_API_KEY
# unset and the Anthropic SDK trades the platform identity token for short-lived
# access tokens, refreshing them on its own.
# export ANTHROPIC_FEDERATION_RULE_ID=fdrl_xxxxxxxxxxxx
# export ANTHROPIC_ORGANIZATION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# export ANTHROPIC_SERVICE_ACCOUNT_ID=svac_xxxxxxxxxxxx
# export ANTHROPIC_IDENTITY_TOKEN_FILE=/var/run/secrets/anthropic/token  # e.g. a projected k8s service-account token

# 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

# Ollama Cloud (hosted Ollama endpoint, requires API key)
export HINDSIGHT_API_LLM_PROVIDER=ollama-cloud
export HINDSIGHT_API_LLM_API_KEY=your-ollama-cloud-api-key
export HINDSIGHT_API_LLM_MODEL=gemma3:12b

# 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

# llama.cpp (built-in local inference, no external server needed)
export HINDSIGHT_API_LLM_PROVIDER=llamacpp
# No API key, base URL, or external server required.
# Auto-downloads Gemma 4 E2B (~3.5 GB GGUF) on first run.
# See "Built-in llama.cpp" section below for all configuration options.

# OpenAI-compatible endpoint (Chat Completions API, /v1/chat/completions)
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-compatible endpoint (Responses API, /v1/responses)
# Same as above but targets /v1/responses instead of /v1/chat/completions.
export HINDSIGHT_API_LLM_PROVIDER=openai-responses
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.6-luna
# No API key needed - uses OAuth tokens from ~/.codex/auth.json
# For long-running services, set CODEX_HOME to a dedicated auth directory so
# Hindsight doesn't share (and lose) its refresh token with another Codex process.
# See Models docs → "Isolating Codex auth for long-running services".
# export CODEX_HOME=/var/lib/hindsight/codex
# Per-provider override of CODEX_HOME. Set it (with the indexed
# HINDSIGHT_API_LLM_<n>_CODEX_HOME below) to run two independently authorized
# ChatGPT profiles in one process.
# export HINDSIGHT_API_LLM_CODEX_HOME=/var/lib/hindsight/codex-a

# Claude Code (Claude Pro/Max subscription - uses OAuth)
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.
# Set HINDSIGHT_API_LLM_API_KEY to a token from `claude setup-token` to authenticate with a key instead.

# Cursor (Cursor subscription - drives the cursor-agent CLI in headless mode)
export HINDSIGHT_API_LLM_PROVIDER=cursor
export HINDSIGHT_API_LLM_MODEL=auto   # or any id from `cursor-agent --list-models`
# No API key needed - uses `cursor-agent login` credentials.
# Set HINDSIGHT_API_LLM_API_KEY (or CURSOR_API_KEY) to authenticate with a key instead.
# An agent CLI turn takes 15-30s, well past the 30s reflect default, so raise the
# timeouts or reflect burns its first iteration on a timeout retry.
export HINDSIGHT_API_LLM_TIMEOUT=300
export HINDSIGHT_API_REFLECT_LLM_TIMEOUT=180
# The CLI exposes no response-format or tool-definition flags, so Hindsight emulates
# structured output and tool calling through the prompt and parses the JSON back. That
# is less reliable than a native schema: prefer a stronger named model over `auto` for
# reflect, which is the operation that leans hardest on tool calls.

# Volcano Engine (ByteDance - OpenAI-compatible)
export HINDSIGHT_API_LLM_PROVIDER=volcano
export HINDSIGHT_API_LLM_API_KEY=your-api-key
export HINDSIGHT_API_LLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
export HINDSIGHT_API_LLM_MODEL=doubao-pro-32k

# OpenRouter (OpenAI-compatible, access 100+ models)
export HINDSIGHT_API_LLM_PROVIDER=openrouter
export HINDSIGHT_API_LLM_API_KEY=your-openrouter-api-key
export HINDSIGHT_API_LLM_MODEL=qwen/qwen3.5-9b
# Structured-output calls (fact extraction, consolidation) send
# provider.require_parameters=true, so OpenRouter only routes them to upstreams
# that support response_format. Any "provider" keys you set in
# HINDSIGHT_API_LLM_EXTRA_BODY (e.g. "only") are kept and take precedence.
# That also means every parameter sent must be supported, temperature included.
# Models that reject temperature (e.g. anthropic/claude-haiku-5.5) then fail with
# "No endpoints found that can handle the requested parameters". Fix: omit it.
# export HINDSIGHT_API_LLM_TEMPERATURE=none
# Reflect forces tool calls: tool_choice "required" after its first searches and a
# named tool on the forced steps. Some upstreams accept only "auto" and fail those
# turns with "No endpoints found that support the provided 'tool_choice' value"
# (reported for several gpt-oss-120b hosts, #5832). Pin ones that support it with
# HINDSIGHT_API_LLM_EXTRA_BODY='{"provider": {"only": ["deepinfra", "cerebras"]}}'.

# Requesty (OpenAI-compatible gateway)
export HINDSIGHT_API_LLM_PROVIDER=requesty
export HINDSIGHT_API_LLM_API_KEY=your-requesty-api-key
export HINDSIGHT_API_LLM_MODEL=openai/gpt-4o-mini

# DeepSeek (OpenAI-compatible, https://api.deepseek.com)
export HINDSIGHT_API_LLM_PROVIDER=deepseek
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash
# Notes:
# - `deepseek-v4-flash` defaults to thinking mode at the API level (treated as
#   `deepseek-reasoner`). Hindsight handles this transparently; the reflect
#   agent will not crash with "deepseek-reasoner does not support this
#   tool_choice".
# - Use `deepseek-v4-pro` for the higher-quality reasoning route.
# - Use `deepseek-chat` for the non-thinking alias (faster, cheaper).

# z.ai (Zhipu GLM series, OpenAI-compatible, https://z.ai)
export HINDSIGHT_API_LLM_PROVIDER=zai
export HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
export HINDSIGHT_API_LLM_MODEL=glm-4.5-flash  # or glm-4.5-air for the paid tier
# Default base_url: https://api.z.ai/api/coding/paas/v4 (override with HINDSIGHT_API_LLM_BASE_URL if needed)

# opencode-go (OpenAI-compatible)
export HINDSIGHT_API_LLM_PROVIDER=opencode-go
export HINDSIGHT_API_LLM_API_KEY=your-opencode-go-api-key
export HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash
# Default base_url: https://opencode.ai/zen/go/v1 (override with HINDSIGHT_API_LLM_BASE_URL if needed)

# Meta Model API (Muse Spark, OpenAI-compatible, https://ai.developer.meta.com)
export HINDSIGHT_API_LLM_PROVIDER=meta
export HINDSIGHT_API_LLM_API_KEY=your-meta-model-api-key
export HINDSIGHT_API_LLM_MODEL=muse-spark-1.3
# Default base_url: https://api.meta.ai/v1 (override with HINDSIGHT_API_LLM_BASE_URL if needed)
# Muse Spark always reasons: leave HINDSIGHT_API_LLM_REASONING_EFFORT unset or set it to
# minimal/low/medium/high/xhigh. "none" is rejected with HTTP 400. Reasoning tokens are
# billed against the output budget, so keep the per-operation max-token limits generous.
# Muse Spark reasons before every reply, so calls are slow: reflect's 30s default
# deadline is not enough for its final synthesis and the call fails after retries.
# Raise it (and the global deadline) when using this provider:
export HINDSIGHT_API_REFLECT_LLM_TIMEOUT=300
export HINDSIGHT_API_LLM_TIMEOUT=300

# Nous Portal (OpenAI-compatible; no API key — uses your `hermes portal` login)
export HINDSIGHT_API_LLM_PROVIDER=nous
export HINDSIGHT_API_LLM_MODEL=deepseek/deepseek-v4-flash
# No API key needed — reads a rotating JWT from ~/.hermes/auth.json (run `hermes portal` first).
# Default base_url: https://inference-api.nousresearch.com/v1 (override with HINDSIGHT_API_LLM_BASE_URL if needed)
# See the "Nous Portal Setup" section in the Models guide for the login flow.

# SuperGrok subscription (device-code OAuth; no API key). This is the subscription
# lane, NOT xAI API support — for API-key access to api.x.ai use
# HINDSIGHT_API_LLM_PROVIDER=openai with HINDSIGHT_API_LLM_BASE_URL=https://api.x.ai/v1.
export HINDSIGHT_API_LLM_PROVIDER=xai-oauth
export HINDSIGHT_API_LLM_MODEL=grok-4.5
# No API key needed — reads an OAuth grant from ~/.hindsight/xai_oauth.json.
# Log in once: python -m hindsight_api.engine.providers.xai_oauth_auth login
# Default base_url: https://api.x.ai/v1
# See the "SuperGrok Subscription Setup" section in the Models guide for the login flow.

# AWS Bedrock (native support - no API key needed, uses AWS credentials)
export HINDSIGHT_API_LLM_PROVIDER=bedrock
export HINDSIGHT_API_LLM_MODEL=us.amazon.nova-2-lite-v1:0
export AWS_ACCESS_KEY_ID=your-access-key
export AWS_SECRET_ACCESS_KEY=your-secret-key
export AWS_REGION_NAME=us-east-1
# Optional: Use Flex tier for 50% cost savings (with variable latency)
# export HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER=flex

# LiteLLM (100+ providers via LiteLLM SDK)
# Azure OpenAI via LiteLLM
export HINDSIGHT_API_LLM_PROVIDER=litellm
export HINDSIGHT_API_LLM_API_KEY=your-azure-api-key
export HINDSIGHT_API_LLM_MODEL=azure/gpt-4o

# Together AI via LiteLLM
export HINDSIGHT_API_LLM_PROVIDER=litellm
export HINDSIGHT_API_LLM_API_KEY=your-together-api-key
export HINDSIGHT_API_LLM_MODEL=together_ai/meta-llama/Llama-3-70b-chat-hf

# No LLM (chunk storage + semantic search only, no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=none
# Retain automatically uses chunks mode (no fact extraction)
# Recall works normally (semantic search, BM25, graph retrieval)
# Reflect returns HTTP 400 (requires an LLM)
# Consolidation/observations are disabled
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_LLM_PROVIDER=litellmrouter
export HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG='{
  "model_list": [
    {"model_name": "default",  "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-..."}},
    {"model_name": "fallback", "litellm_params": {"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-ant-..."}}
  ],
  "fallbacks": [{"default": ["fallback"]}]
}'
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Primary OpenAI, failover to Groq then Anthropic
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-...
export HINDSIGHT_API_LLM_1_PROVIDER=groq
export HINDSIGHT_API_LLM_1_API_KEY=gsk-...
export HINDSIGHT_API_LLM_2_PROVIDER=anthropic
export HINDSIGHT_API_LLM_2_API_KEY=sk-ant-...
export HINDSIGHT_API_LLM_STRATEGY='{"mode": "failover"}'

# Weighted round-robin: serve the primary 3x as often as member 1
export HINDSIGHT_API_LLM_STRATEGY='{"mode": "round-robin", "weights": [3, 1]}'

# Failover, re-probing a failed member after 5 minutes (then 10, 20, ... up to 1h)
export HINDSIGHT_API_LLM_STRATEGY='{"mode": "failover", "cooldown_seconds": 300}'
```

#### Metadata routing

`{"mode": "metadata"}` sends each retained item to the chain member its own metadata selects, so one deployment can extract different documents with different models:

```bash
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-...
export HINDSIGHT_API_LLM_1_PROVIDER=ollama
export HINDSIGHT_API_LLM_1_MODEL=qwen3:8b
export HINDSIGHT_API_LLM_STRATEGY='{
  "mode": "metadata",
  "routes": [{"key": "classification", "value": "sensitive", "member": 1}]
}'
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Minimal setup (auto-downloads model, uses GPU)
export HINDSIGHT_API_LLM_PROVIDER=llamacpp

# Custom model with tuning
export HINDSIGHT_API_LLM_PROVIDER=llamacpp
export HINDSIGHT_API_LLM_MAX_CONCURRENT=2
export HINDSIGHT_API_LLAMACPP_MODEL_PATH=~/.hindsight/models/my-model.gguf
export HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE=16384
export HINDSIGHT_API_LLAMACPP_NO_GRAMMAR=true  # faster, less reliable JSON
export HINDSIGHT_API_LLAMACPP_EXTRA_ARGS="--n_threads 8"
```
/* Detailed source-code truncated for AI context efficiency. */
```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
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
pip install 'hindsight-api-slim[local-onnx]'
# or, in this repository:
uv sync --project hindsight-api-slim --extra local-onnx
```

CUDA execution is opt-in. Set `HINDSIGHT_API_EMBEDDINGS_ONNX_DEVICE=cuda` only in an environment that has a compatible `onnxruntime-gpu` wheel and CUDA/cuDNN runtime. The official images intentionally keep the CPU runtime and do not grow when this feature is unused. For a Docker deployment, use the ready-to-build recipe at [`docker/docker-compose/cuda-onnx/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/cuda-onnx), which installs the GPU wheel into a private image based on the slim image:

```bash
docker compose -f docker/docker-compose/cuda-onnx/docker-compose.yaml up --build
```

The recipe accepts `BASE_IMAGE`, `ONNXRUNTIME_GPU_VERSION`, and `HINDSIGHT_API_EMBEDDINGS_ONNX_CUDA_DEVICE_ID` for release, runtime, and device selection. Hindsight fails startup when CUDA is requested but the provider is unavailable or the initialized session does not activate it; it does not silently fall back to CPU in that mode. The recipe installs CUDA/cuDNN libraries in the private image; the host needs a compatible NVIDIA driver and GPU passthrough. Individual operators may still run on CPU through normal graph partitioning. See the [recipe README](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/cuda-onnx) for dependency isolation, unreleased-checkout builds, and real GPU tests.

You can either let Hindsight download the model from Hugging Face at startup by setting `HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID`, or pre-download the ONNX graph and tokenizer files under the Hindsight repository root.

```bash
cd /path/to/hindsight
mkdir -p models

MODEL_ID=intfloat/multilingual-e5-small
MODEL_DIR=models/intfloat__multilingual-e5-small

uv run --project hindsight-api-slim --extra local-onnx python - <<'PY'
import os
from huggingface_hub import snapshot_download

snapshot_download(
    repo_id=os.environ["MODEL_ID"],
    local_dir=os.environ["MODEL_DIR"],
    allow_patterns=[
        "onnx/model.onnx",
        "onnx/model.onnx_data",
        "*.json",
        "*.txt",
        "*.model",
    ],
)
PY
```

Then start Hindsight with paths relative to the repository root:

```bash
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
export HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH=./models/intfloat__multilingual-e5-small/onnx/model.onnx
export HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=./models/intfloat__multilingual-e5-small
export HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
export HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX="query: "
export HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX="passage: "
```

For Docker deployments, mount the same model directory and use container paths:

```yaml
services:
  hindsight:
    volumes:
      - ./models:/app/models:ro
    environment:
      HINDSIGHT_API_EMBEDDINGS_PROVIDER: onnx
      HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH: /app/models/intfloat__multilingual-e5-small/onnx/model.onnx
      HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH: /app/models/intfloat__multilingual-e5-small
      HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS: "384"
      HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX: "query: "
      HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX: "passage: "
```

Model-specific examples:

```bash
# sentence-transformers/all-MiniLM-L6-v2: 384 dimensions, no E5 prefixes
export HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=sentence-transformers/all-MiniLM-L6-v2
export HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
export HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX=""
export HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX=""

# intfloat/multilingual-e5-small: 384 dimensions, keep E5 prefixes
export HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
export HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
export HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX="query: "
export HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX="passage: "

# sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2: 384 dimensions, no E5 prefixes
export HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
export HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
export HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX=""
export HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX=""

# BAAI/bge-m3: 1024 dimensions, no E5 prefixes; keep onnx/model.onnx_data next to model.onnx
export HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=BAAI/bge-m3
export HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=1024
export HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX=""
export HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX=""
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Local (default) - uses SentenceTransformers
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=***  # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small  # 1536 dimensions by default
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_DIMENSIONS=384  # optional reduced output size

# OpenAI Codex OAuth - uses existing ChatGPT/Codex login, no API key needed
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai-codex
export HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small  # 1536 dimensions by default
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_DIMENSIONS=384  # optional reduced output size

# 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

# OpenRouter - access 100+ embedding models
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openrouter
export HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY=your-openrouter-api-key  # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL=perplexity/pplx-embed-v1-0.6b

# ZeroEntropy - zembed-1
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy
export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1
export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280
# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT=base64  # optional
# export HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY=fast  # optional

# NVIDIA NIM - asymmetric retrieval embeddings (sends input_type=query/passage)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=nvidia
export HINDSIGHT_API_EMBEDDINGS_NVIDIA_API_KEY=nvapi-your-key
# export HINDSIGHT_API_EMBEDDINGS_NVIDIA_MODEL=nvidia/llama-nemotron-embed-vl-1b-v2  # default
# export HINDSIGHT_API_EMBEDDINGS_NVIDIA_DIMENSIONS=1024                            # default

# Cohere - cloud-based embeddings
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0  # 1024 dimensions
# Optional: override output dimensions (for Matryoshka-capable models)
# export HINDSIGHT_API_EMBEDDINGS_COHERE_OUTPUT_DIMENSIONS=512

# Azure-hosted Cohere - embeddings via custom endpoint
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
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_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
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_DIMENSIONS=1536  # optional: declare the width, skip the startup probe

# Google - Gemini API (API key auth)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY=xxxxxxxxxxxx  # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001  # 768 dimensions (default)
# export HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY=768  # configurable: 256, 512, 768, 1024, etc.

# Google - Vertex AI auth (auto-detected when project ID is set)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001
export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID=your-gcp-project-id  # falls back to HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
# export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION=us-central1  # falls back to HINDSIGHT_API_LLM_VERTEXAI_REGION
# export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/key.json  # falls back to LLM config, or uses ADC

# LiteLLM SDK - direct API access without proxy server (recommended)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm-sdk
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY=your-provider-api-key
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL=cohere/embed-english-v3.0
# Optional: request a specific output dimension when the provider supports it
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS=768
# Optional (any provider): the input-token limit inputs are truncated to.
# Defaults to 8192; set it to your model's real limit, or 0 to send text uncapped
# export HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS=8192

# Supported LiteLLM SDK embedding providers:
# - cohere/embed-english-v3.0 (1024 dimensions)
# - openai/text-embedding-3-small (1536 dimensions)
# - together_ai/togethercomputer/m2-bert-80M-8k-retrieval
# - huggingface/sentence-transformers/all-MiniLM-L6-v2
# - voyage/voyage-2
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Primary: a self-hosted reranker that is not always up.
export HINDSIGHT_API_RERANKER_PROVIDER=tei
export HINDSIGHT_API_RERANKER_TEI_URL=http://workstation:8081

# Member 1: a hosted reranker to fall back on.
export HINDSIGHT_API_RERANKER_1_PROVIDER=cohere
export HINDSIGHT_API_RERANKER_1_COHERE_API_KEY=...

# Member 2: last resort — no reranking, keep the fusion order rather than fail.
export HINDSIGHT_API_RERANKER_2_PROVIDER=rrf
```

Ending the chain with `rrf` makes recall **fail open**: results come back in the order
the retrieval stages produced, exactly as if no reranker were configured, instead of
the request failing. Without an `rrf` member (or with every member down) the error still
surfaces, which is the default behaviour.

Notes:

- Members are tried in order on every request; there is no circuit breaker, so a member
  that is down costs its own timeout on each request before the next one is tried. Keep
  the timeouts of an unreliable primary short (`HINDSIGHT_API_RERANKER_TEI_HTTP_TIMEOUT`
  and the per-provider `*_TIMEOUT` settings).
- A member that fails to initialize at startup is logged, not fatal — it is retried on
  the next request that reaches it.
- Scores are not comparable across providers (some return calibrated `[0, 1]` relevance,
  others logits), so a request served by a fallback member can score differently from one
  served by the primary. Failovers are logged at `WARNING`.
- The indexed members are credential fields — never returned by the bank-config API, and
  server-level only (not per-bank configurable).

:::tip Sizing a TEI reranker

TEI reserves one slot per text in a rerank request and rejects the request outright
once its pool is full, so start it with at least
`HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT × HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE`
slots — 1024 for the defaults above, versus TEI's own default of 512:

```bash
text-embeddings-router --max-concurrent-requests 2048 ...
```

Below that threshold, heavy recall traffic makes the reranker reject work it has the
capacity to do. Rejections are retried with backoff, so an undersized pool shows up as
slower recall rather than errors.

:::

```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

# 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

# OpenRouter - access reranking models via OpenRouter
export HINDSIGHT_API_RERANKER_PROVIDER=openrouter
export HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY=your-openrouter-api-key  # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_RERANKER_OPENROUTER_MODEL=cohere/rerank-v3.5

# Cohere - cloud-based reranking
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0

# Any Cohere-compatible /rerank endpoint (Azure AI Foundry, Jina, Voyage, self-hosted BGE, etc.)
#
# Setting HINDSIGHT_API_RERANKER_COHERE_BASE_URL switches the `cohere` provider
# off the Cohere SDK and onto a plain HTTP client that speaks the standard
# Cohere rerank wire format:
#   Request:  POST {base_url}  (or {base_url}/rerank, depending on host)
#             Authorization: Bearer <api_key>
#             {"model": "...", "query": "...", "documents": [...], "return_documents": false}
#   Response: {"results": [{"index": 0, "relevance_score": 0.9}, ...]}
#
# Any service implementing this contract works here. For Azure AI Foundry the
# base_url is the full invoke URL; for SiliconFlow you can also use the
# dedicated `siliconflow` provider below.
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0  # whatever model the endpoint serves
export HINDSIGHT_API_RERANKER_COHERE_BASE_URL=https://your-cohere-compatible-endpoint.com

# ZeroEntropy - cloud-based reranking (state-of-the-art accuracy)
export HINDSIGHT_API_RERANKER_PROVIDER=zeroentropy
export HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY=your-api-key
export HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL=zerank-2  # or zerank-2-small
# export HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL=https://your-custom-endpoint.com  # optional

# SiliconFlow - cloud reranking via SiliconFlow's Cohere-compatible /rerank endpoint
export HINDSIGHT_API_RERANKER_PROVIDER=siliconflow
export HINDSIGHT_API_RERANKER_SILICONFLOW_API_KEY=your-api-key
export HINDSIGHT_API_RERANKER_SILICONFLOW_MODEL=BAAI/bge-reranker-v2-m3
# export HINDSIGHT_API_RERANKER_SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1  # default

# NVIDIA NIM - native reranking API (POST {base}/retrieval/{model}/reranking)
export HINDSIGHT_API_RERANKER_PROVIDER=nvidia
export HINDSIGHT_API_RERANKER_NVIDIA_API_KEY=nvapi-your-key
# export HINDSIGHT_API_RERANKER_NVIDIA_MODEL=nvidia/llama-nemotron-rerank-vl-1b-v2  # default

# TypeSafe - relevance judged by a typed-decision model (Jev), not a /rerank endpoint
export HINDSIGHT_API_RERANKER_PROVIDER=typesafe
export HINDSIGHT_API_RERANKER_TYPESAFE_API_KEY=your-api-key
# export HINDSIGHT_API_RERANKER_TYPESAFE_MODEL=jev-latest              # default
# export HINDSIGHT_API_RERANKER_TYPESAFE_PRUNE_CANDIDATES=true          # also prune irrelevant candidates

# Alibaba Cloud DashScope - qwen3-rerank via Cohere-compatible /reranks endpoint
export HINDSIGHT_API_RERANKER_PROVIDER=alibaba
export HINDSIGHT_API_RERANKER_ALIBABA_API_KEY=your-dashscope-api-key  # or set DASHSCOPE_API_KEY
export HINDSIGHT_API_RERANKER_ALIBABA_MODEL=qwen3-rerank  # default, can omit

# LiteLLM proxy - unified gateway for multiple reranking providers (requires running LiteLLM proxy server)
export HINDSIGHT_API_RERANKER_PROVIDER=litellm
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/...

# LiteLLM SDK - direct API access without proxy (recommended for simplicity)
export HINDSIGHT_API_RERANKER_PROVIDER=litellm-sdk
export HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY=your-deepinfra-api-key
export HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL=deepinfra/Qwen3-reranker-8B  # or cohere/rerank-english-v3.0, etc.

# Google Discovery Engine - cloud-based semantic reranking
export HINDSIGHT_API_RERANKER_PROVIDER=google
export HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID=your-gcp-project-id
export HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY=/path/to/service-account.json  # optional, uses ADC if unset
export HINDSIGHT_API_RERANKER_GOOGLE_MODEL=semantic-ranker-default-004  # or semantic-ranker-fast-004

# Jina MLX - Apple Silicon native reranking (no GPU/cloud required)
# Model (~1.2 GB) is downloaded automatically from HuggingFace Hub on first use.
export HINDSIGHT_API_RERANKER_PROVIDER=jina-mlx
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
uvx --from "optimum-onnx[onnxruntime]" optimum-cli export onnx \
  --model nlpai-lab/KURE-Reranker-nano --task text-classification ./models/kure-reranker-nano

export HINDSIGHT_API_RERANKER_PROVIDER=onnx
export HINDSIGHT_API_RERANKER_ONNX_MODEL_PATH=./models/kure-reranker-nano/model.onnx
export HINDSIGHT_API_RERANKER_ONNX_TOKENIZER_NAME_OR_PATH=./models/kure-reranker-nano
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_DECISION_MODEL_PROVIDER=typesafe
export HINDSIGHT_API_DECISION_MODEL_API_KEY=your-typesafe-key
export HINDSIGHT_API_REFLECT_DECISION_MODEL_MODEL=jev-preview        # reflect only
export HINDSIGHT_API_CONSOLIDATION_DECISION_MODEL_PROVIDER=openai    # consolidation on OpenAI
export HINDSIGHT_API_CONSOLIDATION_DECISION_MODEL_API_KEY=your-openai-key
```
/* Detailed source-code truncated for AI context efficiency. */
```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.

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_TENANT_EXTENSION` | Dotted path to the loaded tenant extension. Set to `hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension` to require an API key on every request. | *(none; auth disabled)* |
| `HINDSIGHT_API_TENANT_API_KEY` | Shared API key checked by the built-in API-key extension. Sent by clients as `Authorization: Bearer <key>`. | *(none)* |
| `HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS` | Comma-separated request headers copied into `RequestContext.extra_headers` for extensions to read, matched case-insensitively. Empty means extensions see only the `Authorization` header. | *(none)* |

If you are enabling Memory Defense, see `docs/developer/memory-defense/` for the policy schema, detector catalog, and audit trail.

#### Forwarding extra headers to extensions

A custom `TenantExtension` normally only sees the `Authorization` header, as `RequestContext.api_key`. That is not enough when the bearer token identifies the *proxy* rather than the caller — for example behind an authenticating gateway that presents one shared service identity to Hindsight, and carries the per-caller identity in a second header.

List those headers to have them forwarded:

```bash
export HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS=x-user-assertion,x-request-origin
```
/* Detailed source-code truncated for AI context efficiency. */
```json
{
  "content": [
    {"type": "text",  "text": "To reset the VPN, click the button shown:"},
    {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}},
    {"type": "text",  "text": "...then reconnect."},
    {"type": "file",  "source": {"type": "base64", "media_type": "application/pdf", "data": "..."},
                      "filename": "escalation-policy.pdf"}
  ]
}
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Fireworks batch retain (50% cost savings, async only)
export HINDSIGHT_API_RETAIN_LLM_PROVIDER=fireworks
export HINDSIGHT_API_RETAIN_LLM_API_KEY=fw_xxxxxxxxxxxx
export HINDSIGHT_API_RETAIN_LLM_MODEL=accounts/fireworks/models/llama-v3p1-8b-instruct
export HINDSIGHT_API_FIREWORKS_ACCOUNT_ID=your-account-id
export HINDSIGHT_API_RETAIN_BATCH_ENABLED=true
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_STORE_DOCUMENT_TEXT=false
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_RETAIN_MISSION="Focus on technical decisions, architecture choices, and team member expertise. Deprioritize social or personal information."
```

**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbose` — more detail per fact**

Use when you need richer facts with full context, relationships, and verbosity. Slower and uses more tokens than `concise`.

**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbatim` — store chunks as-is**

Each chunk is stored as a single memory unit with its original text preserved exactly — no summarization or rewriting. The LLM still runs to extract entities, temporal information, and location so the chunk is fully indexed and retrievable. Useful for RAG-style indexing, document ingestion pipelines, or benchmarks where you want the original text in memory rather than LLM-generated summaries.

```bash
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=verbatim
```

**`retain_strategies` / `retain_default_strategy` — per-call extraction strategy**

Named strategies let you ingest different content types into the same bank using different extraction settings. A strategy is a set of hierarchical field overrides applied on top of the resolved bank config.

Any field in the hierarchical config can be overridden per strategy, including `retain_extraction_mode`, `retain_chunk_size`, `retain_structured_chunk_size`, `entity_labels`, `entities_allow_free_form`, `retain_mission`, etc.

Configure strategies via the bank config API:

```json
{
  "retain_default_strategy": "conversations",
  "retain_strategies": {
    "conversations": {
      "retain_extraction_mode": "concise",
      "retain_chunk_size": 3000,
      "retain_structured_chunk_size": 12000
    },
    "documents": {
      "retain_extraction_mode": "chunks",
      "retain_chunk_size": 800,
      "entity_labels": null,
      "entities_allow_free_form": false
    }
  }
}
```

Then specify the strategy at retain time:

<CodeSnippet code={configurationPy} section="retain-strategy" language="python" />

If no `strategy` is specified in a retain call, `retain_default_strategy` is used. If neither is set, the bank/global config applies directly.

**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=chunks` — zero LLM cost**

Each chunk is stored as-is with no LLM call whatsoever. No entity extraction, no temporal indexing — only embeddings are generated for semantic search. User-provided entities passed via `RetainContent.entities` are the sole source of entity data. Use when ingestion speed and cost matter more than structured metadata.

```bash
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=chunks
```

**`HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom` + `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` — full control**

Replaces the built-in selectivity rules entirely. The structural parts of the prompt (output format, temporal handling, coreference resolution) remain intact — only the extraction guidelines are replaced.

Use this when `retain_mission` isn't sufficient and you need strict inclusion/exclusion logic.

```bash
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
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

DO NOT extract:
❌ Greetings or social conversation
❌ Process chatter (\"let me check\", \"one moment\")
❌ Anything that would not be useful in 6 months"
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Server default: try iris first, fall back to markitdown if iris fails
export HINDSIGHT_API_FILE_PARSER=iris,markitdown

# Restrict what clients may request (optional — defaults to all registered parsers)
export HINDSIGHT_API_FILE_PARSER_ALLOWLIST=markitdown,iris
```

```json
// Per-request override (in the JSON body of the file retain endpoint)
{
  "parser": "iris",
  "files_metadata": [
    { "document_id": "report" },
    { "document_id": "fallback_doc", "parser": ["iris", "markitdown"] }
  ]
}
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Configure a dedicated OpenAI-compatible OCR/vision endpoint for MarkItDown OCR
export HINDSIGHT_API_FILE_PARSER=markitdown
export HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true
export HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=your-vision-api-key
export HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=https://vision.example/v1
export HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=ocr-or-vision-model
# Optional custom headers for proxies / request tracing
# export HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_DEFAULT_HEADERS='{"X-Component-Id":"hindsight-ocr"}'
```

#### Parser: iris

Cloud-based extraction via [Vectorize Iris](https://docs.vectorize.io/build-deploy/extract-information/understanding-iris/). Higher quality extraction for complex documents, powered by a remote AI service.

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN` | Vectorize API token | — |
| `HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID` | Vectorize organization ID | — |

**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, JPEG, PNG, GIF, BMP, TIFF, WEBP), HTML, TXT, MD, CSV.

```bash
# Use iris as the only parser
export HINDSIGHT_API_FILE_PARSER=iris
export HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN=your-vectorize-token
export HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID=your-org-id

# Or: try iris first, fall back to markitdown if iris fails or rejects the file type
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
```

#### Parser: llama_parse

Cloud-based extraction via [LlamaParse](https://docs.cloud.llamaindex.ai/llamaparse) (LlamaIndex). Strong extraction for complex layouts — tables, charts, multi-column PDFs.

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY` | LlamaCloud API key (typically starts with `llx-`) | — |

**Supported formats:** PDF, DOCX, PPTX, XLSX, HTML, EPUB, RTF, TXT, and many more — see the [LlamaParse docs](https://docs.cloud.llamaindex.ai/llamaparse/features/supported_document_types) for the full list.

```bash
# Use llama_parse as the only parser
export HINDSIGHT_API_FILE_PARSER=llama_parse
export HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY=llx-your-api-key

# Or: try llama_parse first, fall back to markitdown
export HINDSIGHT_API_FILE_PARSER=llama_parse,markitdown
```

```bash
# Increase batch limits for large file imports
export HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE=20
export HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB=500

# Keep files after processing (useful for debugging or re-processing)
export HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN=false
```

### File Storage

Files uploaded via the file retain API are stored in an object storage backend before conversion. Choose the backend that fits your infrastructure.

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_STORAGE_TYPE` | Storage backend: `native`, `s3`, `gcs`, or `azure` | `native` |
| `HINDSIGHT_API_FILE_STORAGE_EXTENSION` | `module.path:ClassName` naming your own `FileStorage` implementation, used instead of the built-in backends. Every other `HINDSIGHT_API_FILE_STORAGE_*` variable is passed to it as a lowercased config dict. Implement `delete_prefix` too, or deleting a bank leaves that bank's stored files in your backend. | unset |

#### Native (PostgreSQL)

Files are stored as `BYTEA` in the `file_storage` table. No additional infrastructure required. Suitable for development and small deployments.

```bash
# Native storage is the default — no additional configuration needed
export HINDSIGHT_API_FILE_STORAGE_TYPE=native
```

#### S3 / S3-Compatible

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_STORAGE_S3_BUCKET` | S3 bucket name | - |
| `HINDSIGHT_API_FILE_STORAGE_S3_REGION` | AWS region | - |
| `HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT` | Custom endpoint URL (for S3-compatible stores like MinIO, Cloudflare R2, Tigris) | AWS default |
| `HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID` | AWS access key ID | - |
| `HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY` | AWS secret access key | - |

For S3-compatible providers that don't expose AWS-style regions (MinIO, Cloudflare R2, Tigris), set `HINDSIGHT_API_FILE_STORAGE_S3_REGION=auto`. The value is required for SigV4 request signing but is ignored by the service.

```bash
# AWS S3
export HINDSIGHT_API_FILE_STORAGE_TYPE=s3
export HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-hindsight-files
export HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1
export HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

# S3-compatible (MinIO, Cloudflare R2, etc.)
export HINDSIGHT_API_FILE_STORAGE_TYPE=s3
export HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-bucket
export HINDSIGHT_API_FILE_STORAGE_S3_REGION=auto
export HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=https://your-minio.example.com
export HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=minioadmin
export HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=minioadmin

# Tigris (S3-compatible, single global endpoint)
export HINDSIGHT_API_FILE_STORAGE_TYPE=s3
export HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-hindsight-bucket
export HINDSIGHT_API_FILE_STORAGE_S3_REGION=auto
export HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=https://t3.storage.dev
export HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=tid_your_access_key
export HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=tsec_your_secret_key
```

#### Google Cloud Storage

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET` | GCS bucket name | - |
| `HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY` | Path to service account JSON key file | ADC if not set |

```bash
export HINDSIGHT_API_FILE_STORAGE_TYPE=gcs
export HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET=my-hindsight-files
# Optional: use service account key file (otherwise falls back to ADC)
export HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY=/path/to/key.json
```

#### Azure Blob Storage

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER` | Azure container name | - |
| `HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME` | Azure storage account name | - |
| `HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY` | Azure storage account key | - |

```bash
export HINDSIGHT_API_FILE_STORAGE_TYPE=azure
export HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER=hindsight-files
export HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME=mystorageaccount
export HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY=base64encodedkey==
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
HINDSIGHT_API_WORKER_ENABLED=false
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_OBSERVATIONS_MISSION="Observations are broad summaries of project events grouped by week. Each observation should capture what happened, what was decided, and what was blocked — not individual facts. Merge related events into cohesive weekly narratives."
```

**Example: Person-centric knowledge**

```bash
export HINDSIGHT_API_OBSERVATIONS_MISSION="Observations are durable facts about specific named people: their preferences, skills, relationships, and behavioral patterns. Only create observations for facts that are stable over time and tied to a named individual."
```

**Example: Support ticket patterns**

```bash
export HINDSIGHT_API_OBSERVATIONS_MISSION="Observations are recurring patterns in customer support interactions: common failure modes, frequently requested features, and pain points that appear across multiple tickets."
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Expose only recall (read-only deployment)
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall

# Expose recall and reflect only
export HINDSIGHT_API_MCP_ENABLED_TOOLS=recall,reflect
```

Available tool names: `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`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.

This can also be overridden per bank via the [config API](#hierarchical-configuration):

<CodeSnippet code={configurationSh} section="mcp-tools-per-bank" language="bash" />

When a bank-level `mcp_enabled_tools` is set, tools not in the list return a clear error when invoked (they still appear in the tools list for MCP protocol compatibility).

**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."
```
/* Detailed source-code truncated for AI context efficiency. */
```
HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY="shadow-*:10,staging-*:5,*:1"
```
/* Detailed source-code truncated for AI context efficiency. */
```python
from hindsight_api import MemoryEngine

memory = MemoryEngine.from_env()
await memory.initialize()
```
/* Detailed source-code truncated for AI context efficiency. */
```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
```
/* Detailed source-code truncated for AI context efficiency. */
```
HINDSIGHT_API_PROFILE='{"every": 60, "top": 20}'
```

```
[profile] thread hindsight-loop-            1.41 cores
[profile] thread asyncio_                   0.37 cores
[profile] 1598 functions active in the last 60s
[profile] tottime=0.923 cumtime=1.833 ncalls=15842 <method 'encode' of 'builtins.CoreBPE' objects>
[profile] tottime=0.691 cumtime=7.039 ncalls=1511590 <built-in method builtins.isinstance>
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
# Point Control Plane to a remote API service
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888

# Authenticate to an auth-protected API service
export HINDSIGHT_CP_DATAPLANE_API_KEY=my-dataplane-bearer-token

# Protect the Control Plane with an access key
export HINDSIGHT_CP_ACCESS_KEY=my-secret-key
```

### Hierarchical Configuration

Hindsight supports per-bank configuration overrides through a hierarchical system: **Global (env vars) → Tenant → Bank**.

#### Type-Safe Config Access

To prevent accidentally using global defaults when bank-specific overrides exist, Hindsight enforces type-safe config access:

**In Application Code:**
```python
from hindsight_api.config import get_config

# ✅ Access static (infrastructure) fields
config = get_config()
host = config.host  # OK - static field
port = config.port  # OK - static field

# ❌ Attempting to access bank-configurable fields raises an error
chunk_size = config.retain_chunk_size  # ConfigFieldAccessError!
```

**Error Message:**
```
ConfigFieldAccessError: Field 'retain_chunk_size' is bank-configurable and cannot
be accessed from global config. Use ConfigResolver.resolve_full_config(bank_id, context)
to get bank-specific config.
```

**For Bank-Specific Config:**
```python
# Internal code that needs bank-specific settings
from hindsight_api.config_resolver import ConfigResolver

# Resolve full config for a specific bank
config = await config_resolver.resolve_full_config(bank_id, request_context)
chunk_size = config.retain_chunk_size  # ✅ Uses bank-specific value
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
export HINDSIGHT_API_DEFAULT_BANK_TEMPLATE='{"version":"1","bank":{"reflect_mission":"Help support agents remember customer interactions.","retain_extraction_mode":"verbose","disposition_empathy":5},"directives":[{"name":"Be concise","content":"Always respond concisely.","priority":10}]}'
```

#### API Endpoints

- `GET /v1/default/banks/{bank_id}/config` - View resolved config (filtered by permissions)
- `PATCH /v1/default/banks/{bank_id}/config` - Update bank overrides (only allowed fields)
- `DELETE /v1/default/banks/{bank_id}/config` - Reset to defaults

#### Permission System

Tenant extensions can control which fields banks are allowed to modify via `get_allowed_config_fields()`:

```python
class CustomTenantExtension(TenantExtension):
    async def get_allowed_config_fields(self, context, bank_id):
        # Option 1: Allow all configurable fields
        return None

        # Option 2: Allow specific fields only
        return {"retain_chunk_size", "retain_custom_instructions"}

        # Option 3: Read-only (no modifications)
        return set()
```

#### Examples

<CodeSnippet code={configurationSh} section="bank-config-examples" language="bash" />

**Security Notes:**
- Credentials (API keys, base URLs) are never returned in responses
- Only configurable fields can be modified
- Responses are filtered by tenant permissions
- Attempting to set credentials returns 400 error

### Reverse Proxy / Subpath Deployment

To deploy Hindsight under a subpath (e.g., `example.com/hindsight/`):

1. Set both environment variables to the same path:
   ```bash
   HINDSIGHT_API_BASE_PATH=/hindsight
   NEXT_PUBLIC_BASE_PATH=/hindsight
   ```

2. Configure your reverse proxy to:
   - Forward `/hindsight/*` requests to Hindsight
   - Preserve the full path in forwarded requests
   - Set appropriate proxy headers (X-Forwarded-Proto, X-Forwarded-For)

**Example: Nginx Configuration**

```nginx
location /hindsight/ {
    proxy_pass http://localhost:8888/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}
```

**Example: Traefik Configuration**

```yaml
http:
  routers:
    hindsight:
      rule: "PathPrefix(`/hindsight`)"
      service: hindsight
      middlewares:
        - hindsight-stripprefix

  middlewares:
    hindsight-stripprefix:
      stripPrefix:
        prefixes:
          - "/hindsight"

  services:
    hindsight:
      loadBalancer:
        servers:
          - url: "http://localhost:8888"
```

**Important Notes:**
- The base path must start with `/` and should NOT end with `/`
- Both API and Control Plane should use the same base path
- After setting environment variables, restart both services
- OpenAPI docs will be available at `<base-path>/docs` (e.g., `/hindsight/docs`)

**Complete Examples:**

See `docker/compose-examples/` directory for:
- Nginx configuration files (`simple.conf`, `api-and-control-plane.conf`)
- Docker Compose setups (`docker-compose.yml`, `reverse-proxy-only.yml`)
- Traefik and other reverse proxy examples
- Full deployment documentation
---

## 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

# File storage (optional, defaults to PostgreSQL native storage)
# HINDSIGHT_API_FILE_STORAGE_TYPE=s3
# HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-hindsight-files
# HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1
# HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
# HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

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

---

### Hindsight Docs Docs Developer Development (`hindsight-docs/docs/developer/development.md`)

---
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 provider credential, or a local/subscription-backed provider such as Ollama or GitHub Copilot

## 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

Hindsight does not accept pull requests from outside the team. To report a bug or
ask for a feature, [open an issue](https://github.com/vectorize-io/hindsight/issues/new/choose).
See [CONTRIBUTING.md](https://github.com/vectorize-io/hindsight/blob/main/CONTRIBUTING.md)
for details.

## 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
```

---

### Hindsight Docs Docs Developer Extensions (`hindsight-docs/docs/developer/extensions.md`)

# 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
```

**No longer built in: SupabaseTenantExtension**

Validates [Supabase](https://supabase.com) JWTs and gives each authenticated user their own PostgreSQL schema. It now lives in the [extensions registry](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/supabase-tenant), which documents its configuration and ships a Dockerfile that builds an image with it.

:::warning Breaking change in 0.9.3
Up to 0.9.2 this extension was built in, at `hindsight_api.extensions.builtin.supabase_tenant`. That path no longer exists, so an install still pointing at it fails at startup with `ModuleNotFoundError`. Add the extension to your image and set `HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension`. All `HINDSIGHT_API_TENANT_*` settings and the schema naming are unchanged.
:::

**External: StaticKeysTenantExtension**

A fully self-hosted multi-user mode: users and their API keys are declared in environment variables (no external identity provider, no users table). Each user maps to their own PostgreSQL schema (`{prefix}_{user_id}`), provisioned lazily on first access, giving database-level memory isolation between users. Multiple API keys may map to the same user and schema.

User IDs are case-insensitive: they are lowercased (and dashes normalized to underscores) before building the schema name, so `Rafael`, `rafael` and `RAFAEL` all resolve to the same tenant schema.

It lives in the [extensions registry](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/static-keys-tenant), which documents its configuration and ships a Dockerfile that builds an image with it.

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.

Provides two router methods:
- `get_router(memory)` — returns a FastAPI router mounted at `/ext/`
- `get_root_router(memory)` — returns a FastAPI router mounted at the application root (for well-known endpoints or other paths that must be at specific locations). Returns `None` by default.

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

`self.context` is the process-wide extension context — use it for process-global handles such as `get_memory_engine()`. It holds no per-request state: take the tenant and bank from each hook's own argument (`ctx.bank_id` and `ctx.request_context`, the latter holding the identity resolved by the tenant extension).

```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
```

---

### LLMRequestExtension

Decides, per LLM call, which LLM the call goes to and with which credentials. Use it to:

- **Bill each customer on their own key.** Front your LLM spend with a gateway (LiteLLM proxy, Bifrost, OpenRouter, Portkey, ...) that gives each customer a virtual key, and send each bank's calls with its customer's key.
- **Pick the model per bank or per operation.** A cheap model for one bank's retain and a strong one for its reflect, a different provider or cloud account per customer.

It is asked once per LLM call with the tenant schema, the bank and the operation the call runs for — retain, reflect, consolidation, mental-model refresh — in the API process and in the worker alike. It answers with an `LLMRequestOverrides`, or `None` to use the server's own LLM. Raising refuses the call.

```bash
HINDSIGHT_API_LLM_REQUEST_EXTENSION=mypackage.llms:MyLLMs
```

An answer can carry:

| Field | Effect |
| --- | --- |
| `llm` | Send the call to this LLM instead of the server's: provider, model, base URL, key and the other per-LLM settings (`LLMMemberConfig`, the shape of a multi-LLM chain member). Fields left unset inherit the server's global LLM defaults, except its credentials (default headers, Vertex service account, Codex home, LiteLLM router config); timeouts and retries inherit the operation's. |
| `api_key` | The key for the LLM the call goes to (the server's, or `llm`'s — it fills an `llm` that has no key of its own). |
| `headers` | Extra request headers, e.g. a gateway's customer id. |

Things to know:

- **The choice is server-side.** Nothing comes from the API caller. If your extension builds an answer from something a caller can write (bank config, metadata), validate it — otherwise that is where an endpoint or a key would come from user input.
- **Credentials without `llm` cost no new client.** The key and headers go on a per-call copy of the existing SDK client that shares its connection pool. Supported on OpenAI-compatible endpoints (`openai`, `groq`, `deepseek`, `openrouter`, `fireworks`, `lmstudio`, any gateway set as `base_url`, ...), `anthropic` and `litellm` routed to a keyed backend (OpenAI, Anthropic, Azure, Gemini AI Studio, OpenRouter, a LiteLLM proxy, ...). A call to any other provider (`gemini`, `vertexai`, `ollama`, `bedrock`, `litellmrouter`, the OAuth/CLI providers) that the answer gives a key or headers to is **refused**, never sent on the configured credentials. To change the key of one of those, answer with a whole `llm` instead. A key is also refused when the server's default headers carry a credential (`Authorization`, `x-api-key`, `x-portkey-api-key`, ...) the answer does not override, since the SDK would send that one instead.
- **Asked once per LLM in a chain.** With a multi-LLM chain or a provider per operation, each LLM is asked separately; check `target.provider` before handing out a key meant for one of them.
- **Reflect prompt caching is off** while the extension is installed: Gemini's step-by-step reflect cache is created on the server's LLM, outside the calls the extension decides.
- **An `llm` answer is built once and reused**, up to 64 distinct LLMs per process (least recently used are dropped). An extension that answers with one LLM per user on a large user base should use a gateway and `api_key` instead.
- **No batch retain.** `HINDSIGHT_API_RETAIN_BATCH_ENABLED=true` with this extension fails at startup: batch jobs are submitted on the server's LLM.
- **LLM calls only.** Embeddings and reranking keep their own configuration.
- **Lifecycle:** `on_startup()` runs when the memory engine initializes, before any LLM call, and `on_shutdown()` when it closes.
- **Startup check:** the connection check at boot uses the server's own LLM. If it has no usable key (for example, the gateway has none for the server), set `HINDSIGHT_API_SKIP_LLM_VERIFICATION=true`.

Load the same extension in the API and in every worker: a worker without it runs consolidation and refreshes on the server's LLM.

The [`bank-llm-routing`](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/bank-llm-routing) community extension routes on bank-id and operation patterns from configuration, and is a working example of the interface.

---

### ModelCallExtension

Receives every model call the server makes — LLM, embedding, rerank and decision-model calls, successes and failures — once it finishes, as a `ModelCall`: the tenant schema, bank, operation and trace it ran for, scope, provider and model, input / cached / output / thoughts tokens, duration, and the request and response themselves (plus the tool calls, when the model asked for tools). Use it to keep a per-call record of model spend and content outside the `llm_requests` table: object storage, a warehouse, an observability product.

```bash
HINDSIGHT_API_MODEL_CALL_EXTENSION=mypackage.calls:MyRecorder
```

Things to know:

- **`record()` runs on the call path, synchronously.** Only buffer there: no I/O, nothing that blocks. Send from a background task. An exception it raises never fails the call; it is swallowed and logged at DEBUG only, so catch and report your own errors.
- **Lifecycle:** `on_startup()` runs when the memory engine initializes, before any model call; `on_shutdown()` when it closes, after the workers have stopped, so a buffer can be written out completely.
- **It sees the same calls `llm_requests` tracing sees**, whether or not that table is written, and in the API and the worker alike. Load it in both.
- **`input` and `output` are the call's content** (prompts and memories included). Treat what you store accordingly.
- **Operation runs (optional):** override `record_operation(run)` to also receive an `OperationRun` when each retain, recall, reflect, consolidation or mental-model refresh ends, wherever it started (HTTP, MCP or a queued task). It carries the run's wall time, its success or error, the model calls it made (count, summed time and tokens), its calls into the memories store (count and summed time, measured at the store's interface), the engine's timed phases, and for a queued task its id and when it was queued. Every `ModelCall` made inside a run carries the run's id as `operation_id`, so the two join. A run opened inside another (a mental-model refresh's reflect pass) is its own run with `parent_id` set, and keeps only its own figures. Same rule as `record()`: buffer and return.
- **`current_operation_id()`** (`hindsight_api.operation_runs`) returns the run in progress, for a memories store that wants to tag its own requests with it.

---

## 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

### Shipping your own database migrations

An extension that keeps state of its own returns the directory holding its Alembic
revision files, and they are applied in the same migration run as Hindsight's own —
same ordering guarantees, same `alembic_version` table:

```python
class MyExtension(TenantExtension):
    def alembic_version_locations(self) -> list[str]:
        return [str(Path(__file__).parent / "alembic" / "versions")]
```

The directory holds revision files only — there is no `env.py`; Hindsight's own
configures the schema and the connection. The tree is independent of Hindsight's: give
its first revision `down_revision = None` and a `branch_labels` naming your extension,
so an operator can address it (`alembic upgrade <label>@head`).

Independent branches have no ordering between them. A revision that needs a Hindsight
table to exist first must say so explicitly, which orders it without making Hindsight's
revision its parent:

```python
depends_on = ("a1b2c3d4e5f6",)   # a Hindsight revision id
```

A directory that does not exist is skipped with a warning rather than failing the
migration — a misconfigured extension must never leave a database unmigratable.

### 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:
            # Optional headers dict is forwarded in HTTP/MCP error responses
            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))
```

`AuthenticationError` accepts an optional `headers` dict that is forwarded in both HTTP and MCP error responses. This is useful for returning custom headers like `WWW-Authenticate`:

```python
raise AuthenticationError(
    "Authorization required",
    headers={"WWW-Authenticate": 'Bearer realm="example"'},
)
```

### Reading additional request headers

`RequestContext` carries the `Authorization` header as `api_key`. To authenticate on a *different* header — for instance when a gateway terminates auth with one shared identity and forwards the per-caller identity separately — name the headers you want forwarded:

```bash
HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS=x-user-assertion
```

They are available as `context.extra_headers`, keyed by lower-cased name:

```python
async def authenticate(self, context: RequestContext) -> TenantContext:
    assertion = context.extra_headers.get("x-user-assertion")
    if not assertion:
        raise AuthenticationError("x-user-assertion header required")

    user_id = verify_assertion(assertion)  # your verification
    return TenantContext(schema_name=f"tenant_{user_id}")
```

This works on both the HTTP and MCP transports, and the same `RequestContext` is passed to `OperationValidatorExtension` hooks, so a validator can enforce rules against the identity resolved here.

Only headers you list are forwarded, and only when present on the request. The variable is unset by default, so extensions see no header data unless you opt in.

A header sent **more than once** is not forwarded at all, and a warning is logged. There is no safe way to choose between the copies — a proxy may append its trusted value either before or after a client-supplied one — so an extension reading it sees nothing and fails the request, rather than silently accepting a value that may be spoofed. Make sure your proxy *replaces* the identity header it injects instead of appending to it.

:::caution Deferred operations
`extra_headers` describes the request being served. Operations that run later — a queued retain, a scheduled consolidation, a mental-model refresh — are executed by a background worker with no request behind them, so their `RequestContext` carries no headers. Authorize on the header at request time; do not rely on it inside work that continues after the response.
:::

### 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

    def get_root_router(self, memory: MemoryEngine) -> APIRouter | None:
        """Optional: mount routes at the application root (not under /ext/)."""
        router = APIRouter()

        @router.get("/.well-known/my-metadata")
        async def metadata():
            return {"version": "1.0"}

        return router
```

Routes from `get_router` are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
Routes from `get_root_router` are mounted at the app root (e.g., `/.well-known/my-metadata`).

### Example: Custom OperationValidatorExtension

```python
from hindsight_api.extensions import (
    OperationValidatorExtension,
    ValidationResult,
    PrecheckContext,
    RetainContext,
    RecallContext,
    ReflectContext,
    RetainResult,
)

class MyValidator(OperationValidatorExtension):
    # Pre-body validation (optional)
    async def precheck(self, ctx: PrecheckContext) -> ValidationResult:
        if ctx.content_length is not None and ctx.content_length > 10_000_000:
            return ValidationResult.reject("Payload is too large")
        return ValidationResult.accept()

    # 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
```

`precheck` runs before the request body is read or deserialized. Its
`PrecheckContext.content_length` is the parsed `Content-Length` header as an
integer, or `None` when the header is missing or cannot be parsed (for example,
chunked transfer encoding). Use it for cheap size-aware quota or cost guards;
the full `validate_*` hooks still run after parsing and should enforce precise
per-operation limits.

#### Memory curation hooks

Curating a memory (`PATCH /v1/default/banks/{bank_id}/memories/{memory_id}`:
edit, invalidate, or revert) has its own pair of hooks, in addition to the
`validate_bank_write` access check that runs first:

- `validate_memory_update(ctx: MemoryUpdateContext)` runs before any work. The
  context carries the requested `text` (when editing it), the requested `state`,
  and `edits_fields`. Reject here to refuse the curation; the returned
  `status_code` is passed through to the HTTP response.
- `on_memory_update_complete(result: MemoryUpdateResult)` runs once the change
  has committed. `result.action` is `edit`, `invalidate`, `revert`, or `reason`,
  and `result.reembedded_tokens` is the size of the text the engine embedded
  again (0 for a plain invalidation or a reason-only update). An edit or revert
  re-embeds the memory and re-consolidates the bank, so this is the figure to
  meter if curation should cost the same as ingesting that text.

#### Transfer-archive import hooks

Importing a transfer archive (a document import, or a whole-bank restore) brings
pre-extracted facts into a bank without running fact extraction:

- `validate_import(ctx: ImportContext)` runs at submission, before the archive
  is stored or queued. `ctx.documents` lists the archive's documents
  (`ImportArchiveDocument`: `document_id` and the original `content`), so a validator can size the import
  for a quota or cost check. A rejection reaches the caller directly. The
  default accepts.
- Each imported document is then reported to `on_retain_complete` with
  `result.source == RetainSource.IMPORT`, zero LLM token counts, and
  `processed_content_tokens=0`. Use `source` to tell an import apart from a
  re-retain whose chunks were all deduplicated, which also reports
  `processed_content_tokens=0`; ordinary retains report `RetainSource.RETAIN`.

Cloning a bank within the same instance is not an import: it is neither gated
by `validate_import` nor reported to `on_retain_complete`.

#### Confining a caller to a tag scope

When several people share one bank and tags separate what each may see,
`resolve_tag_scope` pins every operation of a caller to a tag filter. Return
the tag groups the caller's reachable data must satisfy (they are AND-ed), or
`None` for no restriction:

```python
from hindsight_api.engine.search.tags import TagGroupLeaf
from hindsight_api.extensions import OperationValidatorExtension, TagScopeContext


class TeamScopes(OperationValidatorExtension):
    async def resolve_tag_scope(self, ctx: TagScopeContext):
        user = ctx.request_context.api_key_id  # or whatever your tenant extension resolved
        # Dan reads his own memories plus the shared team rules.
        return [TagGroupLeaf(tags=[f"user:{user}", "kind:rule"], match="any_strict")]
```

The engine combines the scope with whatever filter the caller sends, so a
caller can narrow its view but never widen it:

- **Filtered reads** — recall, reflect (and every tool it runs), the memory,
  document and mental-model lists, observation scopes, tag lists, the memory
  graph, the timeseries, entities and the entity graph, and the knowledge-base
  tree, search and export — only see rows inside the scope. Entity counts are
  recounted over the visible memories.
- **Reads and writes by id** — a memory, a document and its chunks, a mental
  model or knowledge page — answer `404` when the item is outside the scope.
  A retain that names an existing document outside the scope is refused (`403`),
  since replacing or appending to it would touch someone else's text.
- **Source text follows the document.** A memory can be visible through its own
  tags (say, a `kind:rule` fact extracted from someone's private note) while the
  note is not. Its chunk and document text are only returned when the
  *document's* tags pass the filter. This applies to every tag-filtered recall
  and reflect, with or without an extension.
- **Mental models and knowledge pages** created or updated by a scoped caller
  must carry tags inside the scope (`403` otherwise — the caller could not see
  what it made), and record the scope in their trigger (`scope_tag_groups`),
  which every refresh AND-s with the model's own filter, so they can never be
  built from memories their creator could not read. Background refreshes run
  unscoped and rely on that recorded scope. A recorded scope only ever narrows
  the model and is not editable through the API: to drop it, recreate the model.

Use a `_strict` match mode: the non-strict ones also admit untagged rows, and
an untagged mental model is built from the whole bank.

**Writing is a separate permission.** A caller can read a shared scope without
being allowed to change it. `resolve_write_tag_scope` returns the tags a caller
may write, as shell-style patterns (`user:dan`, `project:*`), or `None` for no
restriction (the default):

```python
class TeamScopes(OperationValidatorExtension):
    async def resolve_write_tag_scope(self, ctx: TagScopeContext):
        user = ctx.request_context.api_key_id
        # Everyone writes their own memories; only Kate writes the team rules.
        return [f"user:{user}", "kind:rule"] if user == "kate" else [f"user:{user}"]
```

Every tag on anything the caller writes or changes must match one of the
patterns, otherwise the write is refused with `403` (an item the caller cannot
even read still answers `404`). An untagged item belongs to everyone, so a
restricted writer cannot produce or change one. That covers:

- **retain** (text and files): each item's tags and the batch's `document_tags`,
  plus every tag the retain strategy's [entity labels](./api/memory-banks#entity-labels)
  with `tag: true` could add (`key:value` for each allowed value, `key:*` for an
  open vocabulary), and explicit `observation_scopes` (`"shared"` writes untagged
  observations). They are checked before extraction, so a refused retain costs
  no LLM call;
- **memories and documents**: editing, invalidating or clearing the observations
  of a memory; updating (including the new tags), reprocessing or deleting a
  document;
- **mental models and knowledge pages**: creating one (its tags), updating,
  refreshing, clearing or deleting one, and renaming, moving or deleting a
  knowledge node (every page under it, and under the folder it moves into);
- **directives**: creating, updating or deleting one. An untagged directive
  steers everyone's reflect, so a restricted writer cannot create one.

Directives are read like reflect reads them: untagged directives apply to
everyone, tagged ones only inside the caller's read scope.

Whole-bank operations reach every memory regardless of tags, so a caller with a
read or write scope cannot run them at all (`403`): export, clone and import,
clearing the bank's memories or observations, deleting the bank, changing or
resetting its config, mission or disposition, and running (or retrying failed)
consolidation on request. The consolidation the engine queues after a scoped
caller's own writes still runs. Operation status never returns the raw task payload to a
scoped caller. Reads that are not tag-scoped (stats, webhooks) still go through
`validate_bank_read` / `validate_bank_write`, which can only allow or deny them.

#### Deferring an operation

In addition to `accept` and `reject`, a `validate_*` hook can ask the
worker to **requeue** the operation for a future time by raising
`DeferOperation`. Use this for backpressure (rate-limited upstream,
quota window not yet open, dependency warming up) — unlike a retry, it
does not increment `retry_count` or write `error_message`. The worker
sets `next_retry_at` to your `exec_date` and the task is invisible to
claim queries until that time.

```python
from datetime import datetime, timedelta, timezone

from hindsight_api.extensions import (
    DeferOperation,
    OperationValidatorExtension,
    RetainContext,
    ValidationResult,
)


class QuotaAwareValidator(OperationValidatorExtension):
    async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
        if not await self._quota_available(ctx.bank_id):
            raise DeferOperation(
                exec_date=datetime.now(timezone.utc) + timedelta(minutes=5),
                reason="bank quota window exhausted",
            )
        return ValidationResult.accept()
```

`DeferOperation` is **worker-only**: do not raise it from
`validate_recall` or `validate_reflect` in synchronous HTTP request
paths — there is no queue to defer to and it will surface as a 500.

Inside the worker, `ctx.request_context.operation_id` is the id of the
operation being run (`None` on synchronous requests; for retains folded
into one execution, the primary operation's id). A hook that decides the
operation should not run at all, rather than fail, can cancel it with
`self.context.get_memory_engine().cancel_operation(...)` and
then raise `DeferOperation`: the row stays `cancelled` and no failure is
recorded.

### 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}"
```

### Example: Custom LLMRequestExtension

```python
from hindsight_api.config import LLMMemberConfig
from hindsight_api.extensions import (
    LLMRequestExtension,
    LLMRequestOverrides,
    LLMRequestRefused,
    LLMRequestTarget,
)


class PerCustomerLLMs(LLMRequestExtension):
    def __init__(self, config: dict[str, str]) -> None:
        super().__init__(config)
        self.keys = load_keys(config["keys_file"])  # your own store: {(schema, bank_id): key}

    async def resolve(self, target: LLMRequestTarget) -> LLMRequestOverrides | None:
        key = self.keys.get((target.schema, target.bank_id))
        if key is None:
            # Refuse rather than return None: None sends the call on the server's own LLM.
            raise LLMRequestRefused(f"no LLM key for bank {target.bank_id!r}")
        if target.operation == "reflect":
            # A stronger model for reflect, on the customer's own account.
            return LLMRequestOverrides(llm=LLMMemberConfig(provider="anthropic", model="claude-sonnet-5-5", api_key=key))
        return LLMRequestOverrides(api_key=key, headers={"x-customer": target.bank_id})
```

`resolve` runs on every LLM call, so cache anything slow. Bank ids are unique per tenant schema only — a multi-tenant deployment must key on `target.schema` too, or one tenant can name a bank after another tenant's and be billed to its key. `LLMRequestOverrides` keeps the LLM and key out of its `repr`; keep them out of your own logs as well.

---

### Example: Custom ModelCallExtension

```python
import asyncio

from hindsight_api.extensions import ModelCall, ModelCallExtension


class JsonlFileRecorder(ModelCallExtension):
    """Appends every model call to a JSONL file, written once a second off the call path."""

    def __init__(self, config: dict[str, str]) -> None:
        super().__init__(config)
        self.path = config.get("path", "model_calls.jsonl")
        self.buffer: list[str] = []
        self.task: asyncio.Task | None = None

    def record(self, call: ModelCall) -> None:
        self.buffer.append(call.model_dump_json())  # buffer only

    async def on_startup(self) -> None:
        self.task = asyncio.create_task(self._flush_forever())

    async def on_shutdown(self) -> None:
        if self.task:
            self.task.cancel()
        self._flush()

    async def _flush_forever(self) -> None:
        while True:
            await asyncio.sleep(1)
            self._flush()

    def _flush(self) -> None:
        lines, self.buffer = self.buffer, []
        if lines:
            with open(self.path, "a") as f:
                f.write("\n".join(lines) + "\n")
```

Calls are recorded in the API and in every worker, from whichever event loop made them, so a production recorder should guard its buffer with a `threading.Lock` and write from a task of its own.

---

## Deploying Custom Extensions

### With Docker

Extensions are not bundled in the image. Build one on top of Hindsight that installs your extension's dependencies and copies it in. Install into the image's virtualenv explicitly — it was created by `uv sync` and ships no `pip` of its own, so a bare `pip install` lands where the server can't see it:

```dockerfile
FROM ghcr.io/vectorize-io/hindsight:latest

RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
      'PyJWT[crypto]>=2.12.0' 'httpx>=0.27.0'

COPY my_extension /app/extensions/my_extension
ENV PYTHONPATH=/app/extensions

# Fail the build, not the first request, if it isn't importable.
RUN /app/api/.venv/bin/python -c "import my_extension"
```

Then point the service at that image and pass the extension's variables as environment. Give the API and worker containers the same extension configuration — the worker uses the tenant extension to enumerate schemas for background consolidation.

See the [extensions registry README](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md#docker-packaging) for the full recipe.

### 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

If you've built an extension that solves a common use case, for example:

- 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

[open a feature request](https://github.com/vectorize-io/hindsight/issues/new?template=feature_request.yml) with a link to your repository, and we can list it in the [extensions registry](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md). Hindsight does not accept pull requests from outside the team, so the team adds registry entries. That README covers the extension layout, the development workflow, and Docker packaging.

Extensions live outside the server so that installing Hindsight does not pull in a vendor's client library, and so changing an extension does not require a Hindsight release. Only extensions that add no dependencies and are useful to any deployment (`ApiKeyTenantExtension`, `MemoryDefenseRegexExtension`) stay in `hindsight_api.extensions.builtin`.

---

### Hindsight Docs Docs Developer Installation (`hindsight-docs/docs/developer/installation.md`)

# Installation

Hindsight can be deployed in several ways depending on your infrastructure and requirements.

:::tip Don't want to manage infrastructure?
**[Hindsight Cloud](https://ui.hindsight.vectorize.io)** is a fully managed service that handles all infrastructure, scaling, and maintenance — [sign up here](https://ui.hindsight.vectorize.io).
:::

## Supported Platforms

Hindsight runs on **Linux**, **macOS**, and **Windows**:

| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) | Notes |
|----------|--------|------------------|--------------------|-------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ | Fully supported, recommended for production |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ | Fully supported |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ slim only | ✅ | Use `hindsight-all-slim` / `hindsight-api-slim`. The full bundle's local ML models (PyTorch, MLX) publish no Intel-Mac wheels, so `pip install hindsight-all` silently backtracks to a months-old release. Pair the slim bundle with a hosted embeddings/reranker provider or the in-process ONNX backend (`hindsight-api-slim[local-onnx]`). |
| **Windows** (x86_64) | ✅ | ✅ | ✅ | Fully supported — see [Windows setup](#windows) for external PostgreSQL option |

All platforms support the embedded database (pg0) for development. On Windows, you can also use an external PostgreSQL installation — see the [Windows](#windows) section for a step-by-step guide.

---

## Prerequisites

### PostgreSQL

Hindsight requires PostgreSQL 14+ with a vector extension for similarity search. The supported extensions are:

- **pgvector** (default)
- **pgvectorscale**
- **vchord**
- **scann** (AlloyDB)

Configure which one to use with `HINDSIGHT_API_VECTOR_EXTENSION`. See [Configuration](./configuration) for details.

**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 one of the supported vector extensions:
- **Supabase** — Managed PostgreSQL with pgvector built-in
- **Neon** — Serverless PostgreSQL with pgvector
- **Azure Database for PostgreSQL** — With pgvector and pgvectorscale support
- **Google AlloyDB** / **AlloyDB Omni** — With pgvector and ScaNN support
- **AWS RDS** / **Cloud SQL** — With pgvector extension enabled
- **Self-hosted** — PostgreSQL 14+ with your preferred vector extension

### LLM Provider

You need an LLM API key for fact extraction, entity resolution, and answer generation. See [Models](./models) for supported providers, model recommendations, and configuration.

### Hardware

Hindsight is designed to run on commodity hardware. The footprint depends mainly on whether the **full** image (which bundles local embedding and reranker models) or the **slim** image (which delegates those to external providers) is used.

| Component | Minimum RAM | Recommended RAM | Notes |
|-----------|-------------|-----------------|-------|
| **API — Full image** | 1.5 GB | 2 GB | Loads local BGE embedder (~130 MB) and MiniLM cross-encoder (~90 MB) into memory, plus PyTorch/ONNX runtime arenas. Idle RSS settles around 0.8–1.0 GB; expect 1.2–1.5 GB under load. |
| **API — Slim image** | 512 MB | 1 GB | No local models. Steady-state RSS is dominated by Python runtime and DB connections. Requires [external embedding and reranker providers](./configuration#embeddings) (e.g. TEI, OpenAI, Cohere). |
| **Control Plane (UI)** | 128 MB | 256 MB | Next.js process, lightweight. |
| **Worker** (if separated) | Same as API image variant | Same as API image variant | Workers load the same models as the API server. |
| **PostgreSQL** | 512 MB | 1 GB+ | Scales with the number of memories and indexes. |

:::tip Reducing the footprint
The bulk of the full image's memory comes from the bundled embedding and reranker models and their PyTorch/ONNX runtimes. To shrink the deployment to a few hundred MB of RAM, switch to the **slim** image and configure [external embedding and reranker providers](./configuration#embeddings).
:::

CPU vs GPU: 2 vCPUs on CPU-only is fine for development and basic workloads. For production traffic, the local reranker (cross-encoder) is the main bottleneck and typically benefits from a GPU to keep recall latency reasonable; alternatively, offload reranking to an [external reranker provider](./configuration#embeddings) (e.g. TEI, Cohere) on dedicated GPU hardware.

---

## 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 -it --pull always --name hindsight --restart unless-stopped --shm-size=1g -p 8888:8888 -p 9999:9999 \
  -e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
  -v hindsight-data:/home/hindsight/.pg0 \
  ghcr.io/vectorize-io/hindsight:latest
```

- **API Server**: http://localhost:8888
- **Control Plane** (Web UI): http://localhost:9999

:::note Persisting data: named volume vs. host bind mount
The container runs as a non-root user (UID 1000). The `hindsight-data` **named volume** above is recommended — Docker creates it owned by the container user, so it works with no extra setup.

If you instead bind-mount a **host directory** (`-v $HOME/.hindsight-docker:/home/hindsight/.pg0`), that directory must be owned by UID 1000, or the embedded database fails to start with `Permission denied`:

```bash
sudo chown -R 1000:1000 $HOME/.hindsight-docker
```

Do **not** run the image under a different UID with `--user` to work around this. The image only defines the `hindsight` user (UID 1000), so any other UID has no `/etc/passwd` entry, and startup crashes in a library that looks the running user up by ID:

```
KeyError: 'getpwuid(): uid not found: 1042'
```

Running as UID 1000 (the default) with the directory chowned to match is the supported way to use a host bind mount. If you cannot chown the directory — for example on a NAS share mounted with a fixed `uid=` — use a named volume instead.
:::

All published images are [signed with Cosign](#verifying-image-signatures) — verification is optional.

:::tip Set a stable `HINDSIGHT_API_WORKER_ID` in production
The worker uses the container hostname as its identity, which Docker sets to the container ID by default. That value changes on every restart, so any task that was being processed when the container went down stays parked under the old ID with no way for the new container to recognize it as its own.

Set `HINDSIGHT_API_WORKER_ID` to a stable value (e.g., `-e HINDSIGHT_API_WORKER_ID=hindsight-prod`) so the worker keeps the same identity across restarts. This is recommended even for single-container deployments. For diagnosis and recovery commands, see [Admin CLI - Recovering stuck operations](./admin-cli#recovering-stuck-or-zombie-operations).
:::

### Docker Image Variants

| Variant | Size (AMD64) | Size (ARM64) | When to use |
|---------|--------------|--------------|-------------|
| **Full** (`latest`) | ~9 GB | ~3.7 GB | Default. Embeddings and reranking run in the image; the LLM is always external, including for local inference. |
| **Slim** (`slim`) | ~500 MB | ~500 MB | Use when you already rely on external services for embeddings and reranking (OpenAI, Cohere, TEI). Significantly smaller image, faster deploys. Requires [external providers](./configuration#embeddings). |

The slim image corresponds to the [`hindsight-api-slim`](#bare-metal-pip) pip package. See [Configuration](./configuration#embeddings) for external provider options.

Neither image bundles llama.cpp, so the built-in `llamacpp` provider is not available in Docker. To run inference locally, start llama.cpp (or Ollama, LM Studio, vLLM) alongside Hindsight and point `HINDSIGHT_API_LLM_BASE_URL` at it — see [`docker/docker-compose/local-llm/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/local-llm) for a working compose file.

### NVIDIA GPU Acceleration (CUDA)

To run in-process local embedding and reranker models on an NVIDIA GPU, build a custom CUDA-enabled image using the recipe in [`docker/docker-compose/cuda/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/cuda):

```bash
docker compose -f docker/docker-compose/cuda/docker-compose.yaml up --build
```

The recipe upgrades in-process PyTorch to CUDA 12.6 and configures GPU passthrough via the NVIDIA Container Toolkit. Build it on the machine that will run it — an emulated cross-architecture image cannot reach the GPU.

The CUDA runtime is additive: the base image's CPU PyTorch wheel stays in the lower layers, so the result is **roughly 11 GB on disk** against the ~9 GB Full image. This is why no CUDA image is published — the cost is only worth paying when you actually have a GPU to use.

See [`docker/docker-compose/cuda/README.md`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/cuda) for prerequisites, manual build steps, and verification.

### Bundling Custom Models in a Custom Image

:::tip Production deployments with non-default local models
If you use a non-default local embedder or reranker, bake the models into a custom image at build time rather than enabling the Helm `modelCache` PVC. See [`docker/docker-compose/custom-models/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/custom-models) for a runnable example.
:::

### Available Tags

```bash
# Standalone (API + Control Plane)
ghcr.io/vectorize-io/hindsight:latest        # Full, latest release
ghcr.io/vectorize-io/hindsight:latest-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:latest-slim

# Control Plane only
ghcr.io/vectorize-io/hindsight-control-plane:latest
```

### Verifying image signatures

Images are signed with [Cosign](https://docs.sigstore.dev/cosign/signing/overview/) keyless OIDC. To verify any tag:

```bash
cosign verify ghcr.io/vectorize-io/hindsight:<tag> \
  --certificate-identity-regexp '^https://github\.com/vectorize-io/hindsight/\.github/workflows/(sign-images|release)\.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
```

---

## 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
```

The chart deploys workers as a StatefulSet, so each pod gets a stable name (e.g. `hindsight-worker-0`) that the worker uses as its `HINDSIGHT_API_WORKER_ID`. Tasks claimed by a pod are recognized as its own across restarts. If you swap the chart for a plain Deployment, set `HINDSIGHT_API_WORKER_ID` explicitly per replica — otherwise hostnames are randomized and previously-claimed tasks become orphaned. See [Admin CLI - Recovering stuck operations](./admin-cli#recovering-stuck-or-zombie-operations) for diagnosis.

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**: Running Hindsight as a standalone service on a host machine.

### Install

```bash
pip install hindsight-api        # Full — works out of the box
pip install hindsight-api-slim   # Slim — requires external services for embeddings, reranking, and the database
```

When using `hindsight-api-slim`, you must configure external providers for all model operations. See [Configuration](./configuration#embeddings) for details.

### 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 |
| | `HINDSIGHT_CP_ACCESS_KEY` | *(none)* | Access key to protect the Control Plane UI. When set, users must enter this key to log in. |

#### 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
```

---

## Windows

**Best for**: Running Hindsight natively on Windows without Docker

Hindsight works on Windows with the embedded database (pg0) out of the box — just install and run:

```powershell
pip install hindsight-api

set HINDSIGHT_API_LLM_PROVIDER=openai
set HINDSIGHT_API_LLM_API_KEY=sk-xxx
set HINDSIGHT_API_LLM_MODEL=gpt-4o-mini

hindsight-api
```

### Using External PostgreSQL (optional)

If you prefer to use your own PostgreSQL instance instead of the embedded database:

```powershell
# Install PostgreSQL
winget install PostgreSQL.PostgreSQL.17

# Build pgvector (requires Visual Studio Build Tools)
git clone https://github.com/pgvector/pgvector.git
cd pgvector

# Open "x64 Native Tools Command Prompt for VS" and run:
set PGROOT=C:\Program Files\PostgreSQL\17
nmake /F Makefile.win
nmake /F Makefile.win install

# Create the database and enable the vector extension
psql -U postgres -c "CREATE DATABASE hindsight;"
psql -U postgres -d hindsight -c "CREATE EXTENSION vector;"
```

Then run Hindsight pointing to your database:

```powershell
pip install hindsight-api

set HINDSIGHT_API_DATABASE_URL=postgresql://postgres@localhost:5432/hindsight
set HINDSIGHT_API_LLM_PROVIDER=openai
set HINDSIGHT_API_LLM_API_KEY=sk-xxx
set HINDSIGHT_API_LLM_MODEL=gpt-4o-mini

hindsight-api
```

- **API Server**: http://localhost:8888

:::tip
You can also use the slim package (`pip install hindsight-api-slim`) if you configure external providers for embeddings and reranking. See [Configuration](./configuration#embeddings) for details.
:::

### Windows + China Network Notes

If you are running on Windows behind China network restrictions:

1. DeepSeek works well for `HINDSIGHT_API_LLM_PROVIDER`, but DeepSeek does not provide an embeddings endpoint.
2. Use local embeddings (recommended for privacy and reliability in restricted networks).
3. Set `HF_ENDPOINT=https://hf-mirror.com` before starting Hindsight so Hugging Face model downloads use a China-accessible mirror.

```powershell
set HF_ENDPOINT=https://hf-mirror.com

set HINDSIGHT_API_LLM_PROVIDER=deepseek
set HINDSIGHT_API_LLM_API_KEY=sk-your-deepseek-key
set HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash
set HINDSIGHT_API_LLM_BASE_URL=https://api.deepseek.com

set HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
set HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5

set HINDSIGHT_API_RERANKER_PROVIDER=flashrank

hindsight-api
```

The `HF_ENDPOINT` variable is used by Hugging Face tooling (`huggingface_hub`), not by Hindsight itself.

---

## Embedded in a Python Application

**Best for**: Using Hindsight programmatically from Python without running a separate server process.

```bash
pip install hindsight-all        # Full — works out of the box (Linux, Windows, Apple Silicon Macs)
pip install hindsight-all-slim   # Slim — requires external services for embeddings, reranking, and the database
```

On Intel (x86_64) Macs, install `hindsight-all-slim` — see [Supported Platforms](#supported-platforms).

`hindsight-all` supports two modes of embedding:

**In-process** (`HindsightServer`): the server runs in a background thread inside your application. Best when you want the tightest integration and are already managing your own process lifecycle.

```python
from hindsight import HindsightServer, HindsightClient

with HindsightServer(llm_provider="openai", llm_api_key="sk-xxx") as server:
    client = HindsightClient(base_url=server.url)
    client.retain(bank_id="alice", content="Alice prefers concise answers.")
    results = client.recall(bank_id="alice", query="How should I respond to Alice?")
```

**Managed subprocess** (`HindsightEmbedded`): the server runs as a background daemon process, shared across multiple Python processes or sessions. The daemon starts on first use and runs until it is stopped.

```python
from hindsight import HindsightEmbedded

client = HindsightEmbedded(llm_provider="openai", llm_api_key="sk-xxx")
client.retain(bank_id="alice", content="Alice prefers concise answers.")
results = client.recall(bank_id="alice", query="How should I respond to Alice?")
```

See the [Python SDK](../sdks/python.mdx) for the full API reference.

---

## Next Steps

- [Configuration](./configuration.mdx) — Environment variables and settings
- [Models](./models.mdx) — ML models and providers
- [Monitoring](./monitoring.md) — Metrics and observability

---

### Hindsight Docs Docs Developer Knowledge Pages (`hindsight-docs/docs/developer/knowledge-pages.mdx`)

---
sidebar_position: 7
---

import Figure from '@site/src/components/Figure';
import knowledgePagesFigure from '@site/figures/knowledge-pages.json';

# Knowledge Pages

**Knowledge pages** are living documents a bank writes about itself. Each page answers one question — "What are the components here?", "What's our error-handling convention?" — and rewrites itself as the bank learns more. They're organized in folders, browsable, searchable, and can be projected onto disk as ordinary markdown files.

The shape is a wiki. The engine underneath is memory.

<Figure doc={knowledgePagesFigure} />

---

## Mental Models, Simplified

A knowledge page *is* a [mental model](./mental-models). Same synthesis, same background refresh, same provenance.

What's different is how much you have to know to use one. A mental model exposes its mechanics — what it reads, when it rebuilds, how it edits itself. Nobody should have to think about synthesis scope and refresh triggers to keep a wiki. So a page comes with those decisions already made:

- It's built from the bank's [observations](./observations) — consolidated, deduplicated beliefs — rather than raw conversational detail.
- It refreshes incrementally whenever consolidation produces new knowledge in its scope, editing the document rather than regenerating it.
- It never reads other pages, so pages can't cite each other into a feedback loop.
- It gets a larger content budget, because it's a document rather than an answer.

You supply a name and a question. Everything else is a default you can override if you need to — every mental model setting still applies. See the [Knowledge Pages API](./api/knowledge-pages) for the exact defaults and how to change them.

---

## Organized Like a Wiki

Pages live in a tree of folders. Nesting is arbitrary, page names are unique within their folder, and deleting a folder deletes its subtree. That's the whole structural model — a hierarchy, the way anyone would organize documents by hand.

The tree is what makes a knowledge base navigable rather than a flat list of synthesized blobs: `Architecture/`, `Runbooks/`, `Decisions/`, each holding pages that stay current on their own.

---

## Projected as Real Files

Agents already know how to work with a filesystem. So the CLI can mirror a bank's knowledge base onto disk:

```bash
hindsight fs mount --bank my-bank
```

The folder tree becomes real directories, each page a real markdown file with YAML frontmatter, kept current by a background refresh loop. From there, everything ordinary works — `ls`, `cat`, `grep`, `rg`, `fzf`, your editor, an agent's file tools. No SDK, no API client, no new vocabulary.

The same content is available as a portable markdown bundle over the API, for exporting or committing elsewhere.

---

## Searchable

Pages are searchable at the **document level**: a query returns whole pages, ranked, with snippets. It combines full-text and semantic matching, fused server-side, with no reranking step — fast enough to be the first thing an agent reaches for.

That last part matters. Search is a tool an agent *chooses* to call, visible in the transcript, rather than content pushed into its context on every turn. Retrieval the agent asked for informs what it's doing; retrieval it didn't ask for tends to derail it.

This is a different path from [recall](./retrieval), which searches individual memories. Use page search to pick a document; use recall for a specific fact.

---

## Why Not Just Raw Files?

If the answer is documents in a tree, haven't we reinvented files — the thing a memory system exists to replace?

The difference is what sits underneath. A file is where information goes to age. Whoever wrote it last wins, contradictions accumulate quietly, and nothing ever reconciles them. Left alone, a hand-maintained wiki becomes a beautifully formatted lie — not because anyone lied, but because keeping it true is a chore, and chores lose.

A knowledge page is a **projected view** over processed memory, the way a database view is not a table. Before a page is written, Hindsight has already done the work files can't do for themselves: extracted facts from the raw sessions, commits, and documents; deduplicated them; and reconciled their contradictions through consolidation. So when a team decided X and later amended it to Y, the page says Y — and can say why — instead of preserving both a paragraph apart.

Your raw documents remain the source of truth about *what was said*. The pages are the reconciled truth about *what holds*.

This is also why pages heal themselves rather than rot: they aren't the storage, they're the rendering. Delete a page and nothing is lost — it re-projects from memory. Try deleting a wiki and getting it back.

---

## Working With Pages

- **Control plane** — the Knowledge Base view renders the tree, page contents, and which pages have fallen behind.
- **HTTP API** — see [Knowledge Pages API](./api/knowledge-pages) for the full endpoint surface.
- **CLI** — `hindsight fs` mirrors a bank to a local folder of markdown files.
- **Agent tools** — the Claude Code plugin exposes `agent_knowledge_*` MCP tools so an agent can list, read, create, and update its own pages during a session.

---

### Hindsight Docs Docs Developer Mcp Server (`hindsight-docs/docs/developer/mcp-server.md`)

---
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" \
  -H "Accept: application/json, text/event-stream" \
  -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

The memory bank is resolved in this priority order:

1. **URL path** (highest priority): `http://localhost:8888/mcp/my-bank/`
2. **X-Bank-Id header**: `--header "X-Bank-Id: 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

## Two Modes

The MCP server operates in two modes depending on the URL:

| Mode | URL | Tools | bank_id |
|------|-----|-------|---------|
| **Single-bank** | `/mcp/{bank_id}/` | 27 tools (memory, mental models, directives, documents, operations, tags, bank management) | Implicit from URL |
| **Multi-bank** | `/mcp/` | All 30 tools including `list_banks`, `create_bank`, `get_bank_stats` | Explicit `bank_id` parameter on each tool |

**Single-bank mode** (recommended) scopes all operations to the bank in the URL. Tools don't expose a `bank_id` parameter.

**Multi-bank mode** exposes all tools with an optional `bank_id` parameter, plus bank management tools (`list_banks`, `create_bank`, `get_bank_stats`).

## Tool Metadata and Instructions

Hindsight can append deployment-specific guidance to the `retain` and `recall` MCP tool descriptions. Set `HINDSIGHT_API_MCP_INSTRUCTIONS` on the API server when clients should see local rules, such as which tags to use or which memories should be retained.

```bash
export HINDSIGHT_API_MCP_INSTRUCTIONS="Use project:<name> tags for project-specific memories."
```

MCP clients that read tool annotations also receive safety hints from the built-in tools:

- Read-only operations such as `recall`, `reflect`, `list_*`, and `get_*` are marked with `readOnlyHint: true`.
- Delete, clear, and invalidate operations are marked with `destructiveHint: true`.
- `openWorldHint` is `false` for the built-in tools because Hindsight operates on its configured memory store rather than the open internet.
- Write operations such as `retain`, `create_*`, `update_*`, `refresh_mental_model`, and `cancel_operation` are not marked destructive.

---

## 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`) |
| `timestamp` | string | No | ISO 8601 timestamp for when the event occurred |
| `tags` | list[string] | No | Tags for organizing and filtering this memory |
| `metadata` | object | No | Key-value metadata to attach (e.g., `{"source": "slack"}`) |
| `document_id` | string | No | Associate this memory with an existing document |

**Example:**
```json
{
  "name": "retain",
  "arguments": {
    "content": "User prefers Python over JavaScript for backend development",
    "context": "programming_preferences",
    "tags": ["user:alice", "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

---

### sync_retain

Store information to long-term memory and wait for completion. Unlike [`retain`](#retain) (which is asynchronous), `sync_retain` blocks until the memory is fully stored and immediately available for recall — useful for read-after-write flows where you query right after storing.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | The fact or memory to store |
| `context` | string | No | Category for the memory (default: `general`) |
| `timestamp` | string | No | ISO 8601 timestamp for when the event occurred |
| `tags` | list[string] | No | Tags for organizing and filtering this memory |
| `metadata` | object | No | Key-value metadata to attach (e.g., `{"source": "slack"}`) |
| `document_id` | string | No | Associate this memory with an existing document |

**Example:**
```json
{
  "name": "sync_retain",
  "arguments": {
    "content": "User prefers Python over JavaScript for backend development",
    "context": "programming_preferences",
    "tags": ["user:alice", "preferences"]
  }
}
```

**When to use:**
- You need the memory queryable immediately after storing (read-after-write)
- A workflow step depends on the stored memory being available before continuing
- Otherwise prefer `retain` (asynchronous) to avoid blocking on storage

---

### recall

Search memories to provide personalized responses.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Natural language search query |
| `max_tokens` | integer | No | Token budget for the facts' text (default: 4096). Per-result metadata (id, tags, scores, dates) is not counted, so the response is several times larger |
| `budget` | string | No | Search thoroughness: `low`, `mid`, or `high` (default: `high`) |
| `types` | list[string] | No | Filter by fact type: `world`, `experience`, `observation`. Defaults to all |
| `tags` | list[string] | No | Filter memories by tags. Omit for no filter |
| `tags_match` | string | No | `any` (default), `all`, `any_strict`, `all_strict`, or `exact`. With `exact`, pass `tags: []` to select the untagged/global scope |
| `tag_groups` | list[object] | No | Compound boolean tag filter. Mutually exclusive with `tags`; each leaf has its own `match` value |
| `query_timestamp` | string | No | ISO 8601 timestamp — recall as if asking at this point in time; anchors relative temporal expressions and recency scoring |
| `min_scores` | object | No | Optional per-stage score floors, e.g. `{"reranker": 0.5}`. Keys: `semantic`/`keyword` (retrieval-level cutoffs), `reranker`/`final` (post-ranking). All inclusive and AND-ed; omit for no filtering. Reranker scores aren't calibrated across queries — calibrate before use |
| `temporal_window` | object | No | An explicit `{"start": ISO, "end": ISO}` period to search over, used instead of reading dates out of the query text. Ranks memories dated inside the window higher; it does not drop the ones outside it, so it can't restrict results to a period |
| `include_chunks` | boolean | No | Also return the source text chunks the facts came from. Use it when you need exact strings (paths, IDs, commands) that extraction may have reworded. Default: `false` |
| `max_chunk_tokens` | integer | No | Max tokens of source chunks when `include_chunks` is set (default: 8192) |
| `include_entities` | boolean | No | Also return observations about the entities in the results. Default: `false` |
| `max_entity_tokens` | integer | No | Max tokens of entity observations when `include_entities` is set (default: 500) |
| `include_source_facts` | boolean | No | For observation results, also return the facts they were built from. Default: `false` |
| `max_source_facts_tokens` | integer | No | Max tokens of source facts when `include_source_facts` is set (default: 4096) |

**Example:**
```json
{
  "name": "recall",
  "arguments": {
    "query": "What are the user's programming language preferences?",
    "tags": ["preferences"],
    "budget": "high"
  }
}
```

**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`) |
| `max_tokens` | integer | No | Maximum tokens in the response (default: 4096) |
| `response_schema` | object | No | JSON Schema for structured output. When provided, the response includes a `structured_output` field |
| `tags` | list[string] | No | Scope memories, observations, mental models, and tagged directives. Omitted tags leave memory retrieval unfiltered but load only untagged directives |
| `tags_match` | string | No | `any` (default), `all`, `any_strict`, `all_strict`, or `exact`. Untagged directives remain global in every mode |
| `include_trace` | boolean | No | Include `tool_trace` and `llm_trace` debugging output. Defaults to `false` to keep responses small |

The MCP tool forwards `tags_match` only when `tags` is present. Pass
`tags: []` with `tags_match: "exact"` to select the empty/global scope for raw
facts, observations, and mental models; directive loading also selects only
untagged directives.

**Example:**
```json
{
  "name": "reflect",
  "arguments": {
    "query": "Based on my past decisions, what architectural style do I prefer?",
    "budget": "mid",
    "tags": ["architecture"]
  }
}
```

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

---

### create_mental_model

Create a mental model — a living document that stays current with your memories. Mental models are pre-computed reflections that get automatically refreshed as new memories are stored.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Human-readable name for the mental model |
| `source_query` | string | Yes | The query used to generate and refresh the model |
| `mental_model_id` | string | No | Custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided |
| `tags` | list[string] | No | Tags for organizing and filtering models |
| `tags_match` | string | No | How the model's tags are matched against memories on refresh: `any`, `all`, `any_strict`, `all_strict`, or `exact`. See the note below on the default |
| `trigger` | object | No | Refresh policy — see [Trigger settings](#trigger-settings) |
| `max_tokens` | integer | No | Maximum tokens for model content (default: 2048) |
| `trigger_refresh_after_consolidation` | boolean | No | Legacy shorthand for `trigger.refresh_after_consolidation` |

`trigger` is the preferred form for refresh settings; `tags_match` and
`trigger_refresh_after_consolidation` remain as shorthands for existing
integrations. Setting the same field both ways is an error rather than one
silently winning.

:::warning Tagged models default to `all_strict`
When a mental model has `tags` but no explicit `tags_match`, its refresh matches memories with **`all_strict`** — a memory must carry **every** one of the model's tags to be included. If your memories use narrow, single-topic tags (e.g. `["project:status"]`) while the model is tagged broadly (e.g. `["projects", "mental-model"]`), the refresh filters out everything and the content comes back empty.

Pass `tags_match: "any"` (the same default that `recall` and `reflect` use) to match memories that carry *any* of the model's tags:

```json
{
  "name": "create_mental_model",
  "arguments": {
    "name": "Current projects",
    "source_query": "Which projects is the user currently working on?",
    "tags": ["projects", "mental-model"],
    "tags_match": "any"
  }
}
```
:::

**Example:**
```json
{
  "name": "create_mental_model",
  "arguments": {
    "name": "Team Directory",
    "source_query": "Who works here and what do they do?",
    "tags": ["team", "people"],
    "tags_match": "any"
  }
}
```

Content generation runs asynchronously. The response includes an `operation_id` to track progress.

---

### list_mental_models

List all mental models in a bank, optionally filtered by tags.

Returns metadata only — `id`, `name`, `tags`, `is_stale` and timestamps. To read a
model's synthesized content, call `get_mental_model` with an id from this list.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tags` | list[string] | No | Filter models by tags |

---

### get_mental_model

Retrieve a specific mental model by ID, including its full content.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mental_model_id` | string | Yes | The ID of the mental model to retrieve |

---

### update_mental_model

Update a mental model's metadata or settings.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mental_model_id` | string | Yes | The ID of the mental model to update |
| `name` | string | No | New name |
| `source_query` | string | No | New source query |
| `tags` | list[string] | No | New tags |
| `max_tokens` | integer | No | New max tokens |
| `trigger` | object | No | Refresh-policy fields to change — see [Trigger settings](#trigger-settings). This is a patch: omitted fields keep their current values |
| `tags_match` | string | No | Legacy shorthand for `trigger.tags_match` |
| `trigger_refresh_after_consolidation` | boolean | No | Auto-refresh after consolidation. Only set when you want to change this setting |

---

### Trigger settings

`trigger` carries a mental model's (or knowledge page's) refresh policy: **when**
it rebuilds itself and **what** it rebuilds from. It accepts every field the HTTP
API accepts, so anything you can configure through `PATCH /mental_models/{id}`
you can also configure from an agent over MCP.

| Field | Type | Description |
|-------|------|-------------|
| `mode` | string | `full` regenerates the content from scratch on each refresh; `delta` makes surgical edits, preserving unchanged sections byte-for-byte. Delta falls back to full when there is no existing content or the source query changed |
| `refresh_after_consolidation` | boolean | Rebuild after each memory consolidation |
| `refresh_cron` | string | UTC five-field cron, e.g. `0 3 * * *` for daily at 03:00 UTC. Only runs when the model is stale, so an unchanged scope costs no LLM call |
| `full_refresh_cron` | string | UTC five-field cron for a scheduled **full** refresh of a delta model, e.g. `0 3 * * 0`. Independent of the two triggers above; skipped when no refresh ran since the last full one |
| `min_refresh_interval_seconds` | integer | Floor between two *automatic* refreshes. Triggers arriving sooner fold into one queued refresh, so a burst of retains costs one rebuild. Explicit refreshes ignore it |
| `fact_types` | list[string] | Which of `world`, `experience`, `observation` the refresh retrieves. Omit for all three |
| `exclude_mental_models` | boolean | Exclude **all** mental models from the refresh, so a model never reflects on its siblings |
| `exclude_mental_model_ids` | list[string] | Exclude specific mental models by ID |
| `tags_match` | string | How the model's tags select memories: `any`, `all`, `any_strict`, `all_strict`, `exact` |
| `tag_groups` | list[object] | Compound boolean tag expressions used *instead of* the model's flat tags |
| `include_chunks` | boolean | Whether the refresh's internal recall returns raw chunk text |
| `recall_max_tokens` | integer | Token budget for facts from the refresh's internal recall |
| `recall_chunks_max_tokens` | integer | Token budget for raw chunks from the refresh's internal recall |
| `budget` | string | How many agent steps a refresh may spend, as a multiple of the server's reflect iteration limit: `low` halves it, `mid` keeps it, `high` doubles it. Omit for `mid` |
| `response_schema` | object | JSON Schema for structured output, stored alongside the markdown under `reflect_response.structured_output` |
| `keep_trace` | boolean | Record how each refresh reached its result under `reflect_response.trace`. The only way to diagnose a cron- or consolidation-driven refresh after the fact |

`refresh_after_consolidation` and `refresh_cron` are **mutually exclusive** — a
model refreshes either after consolidation or on a schedule, never both. Setting
one clears the other.

**On create**, omitted fields take the engine defaults (`mode: full`, no
schedule, all fact types); for a knowledge page they take the page defaults
(`mode: delta`, observation-only, auto-refresh, siblings excluded).

**On update, `trigger` is a patch**: only the fields you send change, so putting
a model on a cron schedule keeps its `fact_types` and `mode`. Send an explicit
`null` to clear a nullable setting. Read the current policy back with
`get_mental_model` (detail `content` or `full`) if you want to inspect before
changing.

```json
{
  "name": "update_mental_model",
  "arguments": {
    "mental_model_id": "team-conventions",
    "trigger": { "refresh_cron": "0 3 * * *", "fact_types": ["observation"] }
  }
}
```

---

### delete_mental_model

Permanently delete a mental model.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mental_model_id` | string | Yes | The ID of the mental model to delete |

---

### refresh_mental_model

Re-generate a mental model's content from the latest memories. Runs asynchronously.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mental_model_id` | string | Yes | The ID of the mental model to refresh |

---

### clear_mental_model

Clear a mental model's content while keeping its definition. After clearing, call `refresh_mental_model` to rebuild it from the latest memories.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `mental_model_id` | string | Yes | The ID of the mental model to clear |

---

### list_banks (multi-bank mode only)

List available memory banks, most recently written first. The response carries the
total number of matching banks alongside the page, so large deployments can be
walked with `offset`.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | No | Case-insensitive substring matched against bank ID and name |
| `limit` | integer | No | Maximum number of banks to return (default: 100) |
| `offset` | integer | No | Number of banks to skip (default: 0) |

---

### create_bank (multi-bank mode only)

Create a new memory bank or retrieve an existing one.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `bank_id` | string | Yes | The ID for the new bank |
| `name` | string | No | Human-friendly name for the bank |
| `mission` | string | No | Mission describing who the agent is and what they're trying to accomplish |

---

### list_directives

List directives in a bank. This management tool does not use reflect's
directive-isolation behavior: omitting `tags` lists every directive, including
tagged directives.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tags` | list[string] | No | Filter using `any` matching. When present, returns untagged/global directives plus directives sharing at least one tag. When omitted or empty, returns all directives |
| `active_only` | boolean | No | Only return active directives (default: `true`) |

---

### create_directive

Create a new directive in a bank.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Human-readable name for the directive |
| `content` | string | Yes | The directive content/instruction |
| `priority` | integer | No | Priority level (higher = more important) |
| `is_active` | boolean | No | Whether the directive is active (default: `true`) |
| `tags` | list[string] | No | Execution scope for the directive. Empty/omitted (default) means global; non-empty means reflect must use a matching tag scope |

---

### delete_directive

Delete a directive by ID.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `directive_id` | string | Yes | The ID of the directive to delete |

---

### list_memories

Browse stored memories with optional filtering and pagination.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | No | Filter by fact type: `world`, `experience`, or `observation` |
| `q` | string | No | Search query to filter memories |
| `limit` | integer | No | Maximum number of results (default: 100) |
| `offset` | integer | No | Number of results to skip for pagination (default: 0) |

---

### get_memory

Retrieve a specific memory by ID.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `memory_id` | string | Yes | The ID of the memory to retrieve |

---

### list_documents

List documents that have been ingested into the memory bank.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | No | Search query to filter documents |
| `limit` | integer | No | Maximum number of results (default: 100) |
| `offset` | integer | No | Number of results to skip for pagination (default: 0) |

---

### get_document

Retrieve a specific document by ID, including its metadata.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `document_id` | string | Yes | The ID of the document to retrieve |

---

### delete_document

Delete a document and all memories linked to it.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `document_id` | string | Yes | The ID of the document to delete |

---

### list_operations

List async operations (retain processing, mental model refresh, etc.) with optional status and type filtering.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `pending`, `running`, `completed`, `failed`, `cancelled` |
| `type` | string | No | Filter by type: `retain`, `consolidation`, `refresh_mental_model`, `file_convert_retain`, `webhook_delivery` |
| `limit` | integer | No | Maximum number of results, 1–100 (default: 20) |
| `offset` | integer | No | Number of results to skip for pagination (default: 0) |
| `exclude_parents` | boolean | No | Exclude parent batch operations (default: false) |

---

### get_operation

Get the status and details of an async operation.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `operation_id` | string | Yes | The ID of the operation to check |

---

### cancel_operation

Cancel a pending or running async operation.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `operation_id` | string | Yes | The ID of the operation to cancel |

---

### list_tags

List all unique tags used in a bank, optionally filtered by pattern.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | No | Glob pattern to filter tags (e.g., `project:*`) |
| `limit` | integer | No | Maximum number of results (default: 100) |
| `offset` | integer | No | Number of results to skip for pagination (default: 0) |

---

### get_bank

Get information about a memory bank, including its name, mission, and disposition.

---

### get_bank_stats (multi-bank mode only)

Get statistics for a memory bank (node/link counts).

---

### update_bank

Update a memory bank's configuration. Updates the bank's name and/or any bank-level configuration fields — only provided fields are updated; omitted fields remain unchanged.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | No | Human-friendly display name for the bank |
| `mission` | string | No | **Deprecated** — alias for `config_updates.reflect_mission` |
| `config_updates` | object | No | Dictionary of configuration fields to update. Supports all bank-configurable fields (see below). Non-configurable or credential fields are rejected |

The `config_updates` object accepts any bank-configurable field by its Python field name, including:

- `reflect_mission` — mission/context for Reflect operations
- `retain_mission` — steers what gets extracted during `retain()`
- `retain_extraction_mode` — `concise` (default), `verbose`, `custom`, `verbatim`, or `chunks`
- `retain_custom_instructions` — custom extraction prompt (active when mode is `custom`)
- `retain_chunk_size` — target maximum characters for each content chunk
- `retain_structured_chunk_size` — maximum characters for a single JSONL line or conversation turn to keep whole
- `retain_chunk_batch_size` — number of chunks to process in parallel
- `enable_observations` — toggle observation consolidation after `retain()`
- `observations_mission` — controls observation synthesis rules
- `disposition_skepticism` — critical evaluation level (1–5)
- `disposition_literalism` — literal vs. abstract interpretation (1–5)
- `disposition_empathy` — emotional context consideration (1–5)
- `entity_labels` — controlled vocabulary for entity classification
- `entities_allow_free_form` — allow labels outside `entity_labels`
- `recall_include_chunks` — include raw chunks in recall results
- `recall_prefer_observations` — default `prefer_observations` for recall calls that omit it
- `recall_max_tokens` — max tokens for recall results
- `mcp_enabled_tools` — tool allowlist for this bank

---

### delete_bank

Permanently delete a memory bank and all its data (memories, documents, entities, mental models).

---

### clear_memories

Clear all memories from a bank without deleting the bank itself. Optionally filter by fact type to only clear specific kinds of memories.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | No | Fact type to clear: `world`, `experience`, or `observation`. If not specified, clears all |

---

### get_knowledge_base_tree

Browse the knowledge base as a nested tree of folders and pages. Each page reports `is_stale`: `false` means it is provably up to date, `true` means the bank has been written to since the page last refreshed.

---

### search_knowledge_base

Find knowledge pages by relevance (hybrid BM25 + vector search over page names and content). Returns ranked pages with a snippet each.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | What to search for |
| `limit` | integer | No | Maximum pages to return, 1–50 (default: 10) |

---

### get_knowledge_page

Read a knowledge page as a markdown document (YAML frontmatter + synthesized body).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `page_id` | string | Yes | The ID of the page to read (a `kp-...` node id) |

---

### create_knowledge_folder

Create a folder in the knowledge base. Folders group pages and hold no content of their own.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Folder name |
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |

---

### create_knowledge_page

Create a page — a living document whose content is synthesized from the bank's memories by running `source_query`. Content is generated asynchronously; use the returned `operation_id` to track completion.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Page name (unique within its folder) |
| `source_query` | string | Yes | The question this page answers and rebuilds itself from |
| `parent_id` | string | No | Parent folder id (a `kf-...` node id). Omit to create at the top level |
| `tags` | array | No | Tags scoping which memories the page is built from |
| `max_tokens` | integer | No | Maximum tokens for the generated content (default: 4096) |
| `trigger` | object | No | Refresh policy — see [Trigger settings](#trigger-settings). Omitted fields keep the knowledge-page defaults: `delta` rebuilds from consolidated observations after each consolidation, ignoring sibling pages |
| `refresh_after_consolidation` | boolean | No | Legacy shorthand for `trigger.refresh_after_consolidation` |

Set `trigger.refresh_cron` to move a page off consolidation-driven rebuilds and
onto a fixed UTC schedule — the two are mutually exclusive, so the cron clears
the auto-refresh while leaving the page's `mode` and `fact_types` alone.

---

### update_knowledge_node

Rename or move a folder/page, and/or update a page's options. Only the arguments you pass are changed. Changing `source_query` schedules an async refresh so the page rebuilds against the new question.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to update |
| `name` | string | No | New name for the node |
| `parent_id` | string | No | Folder id to move the node into, or `"root"` to move it to the top level |
| `source_query` | string | No | Pages only — the new question the page answers |
| `tags` | array | No | Pages only — replacement tag list (pass `[]` to clear) |
| `max_tokens` | integer | No | Pages only — new maximum tokens for the generated content |
| `trigger` | object | No | Pages only — refresh-policy fields to change; see [Trigger settings](#trigger-settings). This is a patch: omitted fields keep their current values |
| `refresh_after_consolidation` | boolean | No | Pages only — legacy shorthand for `trigger.refresh_after_consolidation` |

---

### delete_knowledge_node

Delete a folder or page and its whole subtree. Each deleted page takes its backing mental model with it.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `node_id` | string | Yes | The folder (`kf-...`) or page (`kp-...`) to delete |

:::note
Exporting the knowledge base is deliberately not an MCP tool — it returns the whole
bank as a single markdown bundle. Use the HTTP endpoint
`GET /v1/default/banks/{bank_id}/knowledge-base/export` instead.
:::

---

## 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:
- A bank-specific URL path like `/mcp/alice/` (recommended)
- The `X-Bank-Id` header

---

### Hindsight Docs Docs Developer Mental Models (`hindsight-docs/docs/developer/mental-models.mdx`)

---
sidebar_position: 6
---

import Figure from '@site/src/components/Figure';
import mentalModelsFigure from '@site/figures/mental-models.json';

# Mental Models

A **mental model** is a standing answer to a question about a bank. You define the question once; Hindsight writes the answer, keeps it stored, and rewrites it in the background as the bank learns more.

Where [observations](./observations) are produced automatically and are atomic — one belief at a time — a mental model is deliberately curated: you decide which questions deserve a permanent, always-current answer.

<Figure doc={mentalModelsFigure} />

---

## The Answer Is Already Written

The reason to use a mental model is speed. Reasoning over a bank's memory is expensive — retrieval, synthesis, an LLM writing an answer. A mental model moves all of that off the request path: the work happens in the background, ahead of time, and your application simply **reads the current version**.

Fetching a mental model is a database read. No retrieval, no synthesis, no LLM call, no waiting. An agent that boots by loading its mental models starts with a page of settled knowledge instead of spending its first few seconds rediscovering it.

This also makes answers **consistent**. Two users asking the same question get the same document, because there is only one document — not two independently generated answers that happen to disagree on the details.

Mental models are also the first thing [reflect](./reflect) reaches for. Its retrieval ladder goes:

| Layer | Produced by | Granularity |
|---|---|---|
| **Mental models** | You, explicitly | A whole document per question |
| **Observations** | Consolidation, automatically | One belief per fact cluster |
| **Raw facts** | Retain, automatically | One fact per statement |

Each layer is a cheaper, more settled version of the one below it. If the first step turns up a mental model that is fresh and covers the question, reflect can answer from it instead of descending through observations and raw facts — the same saving, applied inside the agentic loop.

---

## Always Current, Without Asking

A mental model is not a cached answer that goes stale silently. Hindsight tracks whether new memories have arrived that the model is supposed to cover, and rebuilds it when they have — either as soon as new knowledge is consolidated, or on a schedule you set.

The check comes first, and it is scoped: a rebuild only happens when something *within this model's own scope* actually changed. A busy bank does not cause unrelated models to churn, and a scheduled rebuild over an unchanged bank costs nothing.

When a model is handed to the reflect agent, it comes with a freshness signal — whether memories in its scope have landed since it was last written. A model that has fallen behind is still shown, but it no longer short-circuits retrieval, and the agent is expected to check it against the layers below rather than trust it blindly.

---

## Stable Across Rewrites

A document that is rewritten hundreds of times has a problem an LLM cannot solve by being asked nicely: told to "preserve the unchanged parts", it will still drift. Bullets become numbers, casing shifts, sentences get quietly paraphrased. Generating text is what the model does; copying it verbatim is not.

Hindsight can instead refresh a model **incrementally** — applying only the changes the new knowledge implies, and leaving everything else physically untouched rather than regenerating and hoping. A long-lived playbook stays the document you wrote, with the new parts added, instead of slowly becoming a different document that says roughly the same thing.

---

## Scope and Isolation

A mental model's tags decide two things: which memories it is allowed to read, and which callers are allowed to see it. A model scoped to one customer, team, or user is built only from that scope's memories and surfaces only for requests in that scope — the same isolation rules that govern the rest of the bank, applied to synthesized knowledge.

---

## Provenance

A mental model is not free-floating prose. It records the facts and observations it was built from, and it keeps the previous version of its content every time it changes. You can see what the model said last month and what evidence it was standing on — which matters when a model states something surprising and someone needs to know where it came from.

---

**See also:** [Mental Models API](./api/mental-models) — creating, refreshing, and configuring them, including refresh triggers, scoping options, and history.

---

### Hindsight Docs Docs Developer Models (`hindsight-docs/docs/developer/models.mdx`)

import {LLMProvidersGrid} from '@site/src/components/SupportedGrids';
import {LLMProvidersTable} from '@site/src/components/LLMProvidersTable';
import {LLMProviderCapabilities} from '@site/src/components/LLMProviderCapabilities';

# Models

Hindsight uses several machine learning models for different tasks.

## Overview

- **LLM** — Fact extraction, reasoning, and generation. Provider-specific, fully configurable.
- **Embedding** — Vector representations for semantic search. Default: `BAAI/bge-small-en-v1.5`.
- **Cross-Encoder** — Reranking search results. Default: `cross-encoder/ms-marco-MiniLM-L-6-v2`.

Embedding and cross-encoder models are downloaded automatically from HuggingFace on first run.

---

## LLM

Used for fact extraction, entity resolution, mental model consolidation, and answer synthesis.

**Supported providers:**

<LLMProvidersGrid />

Also supports **any OpenAI-compatible API** (e.g., Azure OpenAI, Together AI, Fireworks) and **100+ providers via LiteLLM** (e.g., AWS Bedrock, Azure OpenAI, Together AI).

:::tip OpenAI-Compatible Providers
Hindsight works with any provider that exposes an OpenAI-compatible API. Set `HINDSIGHT_API_LLM_PROVIDER=openai` and point `HINDSIGHT_API_LLM_BASE_URL` at the endpoint that serves `/chat/completions` — for most providers that is the URL ending in `/v1`, **not** the account or resource root.

**Azure OpenAI does not serve the API at the resource root**, so `https://<resource>.openai.azure.com` on its own returns `404 Resource not found`. See [Azure OpenAI Setup](#azure-openai-setup) for the two URL shapes that work.

The `openai` provider talks to the **Chat Completions API** (`/v1/chat/completions`). For the newer **Responses API** (`/v1/responses`), use `HINDSIGHT_API_LLM_PROVIDER=openai-responses` — see the tip below. Both accept a custom `HINDSIGHT_API_LLM_BASE_URL`, so an OpenAI-compatible endpoint that exposes `/v1/responses` works the same way as a Chat Completions one.

See [Configuration](./configuration#llm-provider) for setup examples.
:::

:::tip OpenAI Responses API (reasoning + tools together)
Set `HINDSIGHT_API_LLM_PROVIDER=openai-responses` to call OpenAI's **Responses API** (`/v1/responses`) instead of Chat Completions.

Why it exists: some reasoning models — e.g. `gpt-5.6-terra` — **reject `reasoning_effort` when function tools are present** on Chat Completions (HTTP 400 unless `reasoning_effort="none"`). Reflect is a tool-calling loop, so on the `openai` (Completions) provider that forces the whole operation — including the final synthesis — to run with reasoning disabled. (Hindsight retries that 400 once with `reasoning_effort="none"` so the call still succeeds — but it succeeds without reasoning.) The Responses API keeps the model's chain-of-thought as a first-class reasoning item, so **reasoning and tools coexist**: reflect's search loop runs with a real `HINDSIGHT_API_LLM_REASONING_EFFORT` (e.g. `high`).

Recommended for reasoning models (gpt-5.x, o-series) that use tools. It also honors a custom `HINDSIGHT_API_LLM_BASE_URL`, so any OpenAI-compatible endpoint exposing `/v1/responses` (gateways, Azure-style deployments) can be used just like the Chat Completions path.

See [Configuration](./configuration#llm-provider) for setup examples.
:::

:::info Reasoning/thinking models and `max_tokens`
On a thinking model (Gemini 2.5+/3.x, GPT-5/o-series, Grok reasoning, Claude extended thinking) the provider's output budget covers **reasoning tokens plus visible output** — the reasoning is billed against the same `max_output_tokens`/`max_completion_tokens` cap. A small cap can therefore be fully consumed by reasoning, leaving the visible answer truncated mid-word.

Hindsight keeps the reflect/mental-model `max_tokens` meaning **visible page length**: it is applied as a prompt-level target plus a post-hoc rewrite, **not** as a hard cap on the provider call. Reflect's synthesis call is uncapped by default so reasoning never starves the answer. If you want a hard cost ceiling on that call, set `HINDSIGHT_API_REFLECT_MAX_COMPLETION_TOKENS` — but leave enough headroom above your page length for reasoning, or thinking models will truncate again.

When a Gemini call does hit its cap, Hindsight logs a `truncated at max_output_tokens` warning instead of returning the half-written text as a silent success.
:::

:::tip AWS Bedrock
Set `HINDSIGHT_API_LLM_PROVIDER=bedrock` to use AWS Bedrock models directly. Model names use Bedrock model IDs (e.g., `us.amazon.nova-2-lite-v1:0`). No API key is required — authentication uses AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION_NAME`) or IAM roles. For 50% cost savings on throughput, set `HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER=flex` (see [Configuration](./configuration#llm-provider)).

See [Configuration](./configuration#llm-provider) for setup examples.
:::

:::tip Built-in llama.cpp (fully local, no API key)
Set `HINDSIGHT_API_LLM_PROVIDER=llamacpp` to run a built-in llama.cpp server with no external dependencies. A Gemma 4 E2B GGUF model (~3.5 GB) is auto-downloaded on first run. Requires the `local-llm` extra: `pip install 'hindsight-api-slim[local-llm]'`.

The published Docker image does not bundle `llama-cpp-python` (to keep the image small). For a runnable Docker setup that adds it on top, see [`docker/docker-compose/local-llm/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/local-llm).

See [Configuration](./configuration#built-in-llamacpp) for all options.
:::

:::tip LiteLLM Provider (Azure, Together AI, and more)
Set `HINDSIGHT_API_LLM_PROVIDER=litellm` to use any model supported by [LiteLLM](https://docs.litellm.ai/docs/providers), including **Azure OpenAI**, **Together AI**, **Fireworks AI**, and many more. Model names use LiteLLM's provider prefix format (e.g., `azure/gpt-4o`).

See [Configuration](./configuration#llm-provider) for setup examples.
:::

:::tip LiteLLM Router (fallback chains, load-balancing, per-deployment limits)
Set `HINDSIGHT_API_LLM_PROVIDER=litellmrouter` to run the default LLM through [LiteLLM's Router](https://docs.litellm.ai/docs/routing) — ordered fallback across deployments, load-balanced same-tier routing, weighted picks, per-deployment `rpm`/`tpm` limits, and cooldowns are all available via the [`Router` config](https://docs.litellm.ai/docs/routing#fallbacks). Hindsight passes the JSON config through verbatim.

See [Configuration](./configuration#llm-router-litellm-router) for setup.
:::

### Provider Capabilities

Beyond basic generation, some providers support optional features that lower cost or latency. Hindsight uses each feature automatically when the configured provider supports it.

<LLMProviderCapabilities />

- **Batch API** — submits bulk retain extraction through the provider's asynchronous batch endpoint, typically at ~50% lower cost. Used automatically when available; otherwise calls run synchronously.
- **Explicit prompt caching** — reuses the large, fixed system prefix that retain (fact extraction), consolidation, and the reflect tool-loop send on every call, billing it at the provider's cached-input rate. On Gemini/Vertex this uses the `CachedContent` API. **On by default**; disable with `HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED=false`. Hindsight structures these prompts so the cached prefix is **bank-agnostic** — one cache is shared across all banks rather than one per bank/mission, and creation soft-fails to an uncached call, so it never breaks a request.

:::note
A blank "Explicit prompt caching" cell does not mean a provider has no caching. OpenAI, for example, caches a stable leading prompt prefix **automatically** server-side, so it benefits with no configuration; Anthropic supports caching via `cache_control` breakpoints which can be wired up through the same provider hook. The column tracks only Hindsight's explicit `get_or_create_cached_prefix` hook, which Gemini/Vertex implement today.
:::

### Benchmarks

Not sure which model to use? The **[Model Leaderboard](https://benchmarks.hindsight.vectorize.io/)** benchmarks models across accuracy, speed, cost, and reliability for retain, reflect, and observation consolidation so you can pick the right trade-off for your use case.

[](https://benchmarks.hindsight.vectorize.io/)

### Tested Models

The following models have been tested and verified to work correctly with Hindsight:

| Provider | Model |
|----------|-------|
| **OpenAI** | `gpt-5.2` |
| **OpenAI** | `gpt-5` |
| **OpenAI** | `gpt-5-mini` |
| **OpenAI** | `gpt-5-nano` |
| **OpenAI** | `gpt-4.1-mini` |
| **OpenAI** | `gpt-4.1-nano` |
| **OpenAI** | `gpt-4o-mini` |
| **Anthropic** | `claude-sonnet-4-20250514` |
| **Anthropic** | `claude-3-5-sonnet-20241022` |
| **Gemini** | `gemini-3.5-flash` |
| **Gemini** | `gemini-3.1-pro-preview` |
| **Gemini** | `gemini-3.1-flash-lite` |
| **Groq** | `openai/gpt-oss-120b` |
| **Groq** | `openai/gpt-oss-20b` |
| **Meta** | `muse-spark-1.3` |

### Provider Default Models

Each provider has a recommended default model that's used when `HINDSIGHT_API_LLM_MODEL` is not explicitly set. This makes configuration simpler - just specify the provider and get a sensible default:

<LLMProvidersTable />

**Example:** Setting just the provider uses its default model:
```bash
# Uses claude-haiku-4-5 automatically
export HINDSIGHT_API_LLM_PROVIDER=anthropic
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
```

You can override the default by explicitly setting `HINDSIGHT_API_LLM_MODEL`:
```bash
# Override to use Sonnet instead
export HINDSIGHT_API_LLM_PROVIDER=anthropic
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-5-20250929
```

This also applies to per-operation overrides:
```bash
# Global: OpenAI gpt-4o-mini (default)
export HINDSIGHT_API_LLM_PROVIDER=openai

# Retain: Anthropic claude-haiku-4-5 (default)
export HINDSIGHT_API_RETAIN_LLM_PROVIDER=anthropic
```

### Using Other Models

Other LLM models not listed above may work with Hindsight, but they must support **at least 65,000 output tokens** to ensure reliable fact extraction. If you need support for a specific model that doesn't meet this requirement, please [open an issue](https://github.com/hindsight-ai/hindsight/issues) to request an exception.

:::tip Models with Limited Output Tokens
If your model only supports 32k or fewer output tokens (e.g., some older models), you can reduce the retain completion token limit:

```bash
# For models that support 32k output tokens
export HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS=32000

# For models that support 16k output tokens
export HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS=16000
```

**Important:** `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` must be greater than `HINDSIGHT_API_RETAIN_CHUNK_SIZE` (default: 3000). The system will validate this on startup and provide an error message if the configuration is invalid.
:::

:::warning Groq free tier is not suitable for Hindsight
Groq's free tier only allows 8,000 tokens per minute — far below what Hindsight needs for a single retain call (~64k). Free-tier Groq models therefore can't be used with Hindsight; use a paid Groq tier or a different provider.
:::

### Configuration

```
/* Detailed source-code truncated for AI context efficiency. */
```

**Note:** The LLM is the primary bottleneck for retain operations. See [Performance](./performance) for optimization strategies.

---

### Cheap Memory Model with Failover

Hindsight calls its own LLM, with its own credentials, only to process memory: extracting facts on retain, consolidating observations, refreshing mental models, and answering reflect. Your agent keeps calling whatever model it already uses — Hindsight never sees or changes that. So the memory model can be a cheap, high-throughput one, picked for volume rather than for the agent's reasoning.

A typical production setup puts the cheap model first and stronger, pricier models behind it, used only when the cheap one fails. The building blocks are documented in [Multi-LLM Strategies](./configuration#multi-llm-strategies-failover--round-robin) and [Per-Operation LLM Configuration](./configuration#per-operation-llm-configuration):

```bash
# Member 0 (primary): cheap workhorse, serves almost every call
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=<groq-key>
export HINDSIGHT_API_LLM_MODEL=<cheap-model>

# Member 1: mid-tier fallback
export HINDSIGHT_API_LLM_1_PROVIDER=openai
export HINDSIGHT_API_LLM_1_API_KEY=<openai-key>
export HINDSIGHT_API_LLM_1_MODEL=<mid-model>

# Member 2: top-tier last resort, with its own retry budget
export HINDSIGHT_API_LLM_2_PROVIDER=anthropic
export HINDSIGHT_API_LLM_2_API_KEY=<anthropic-key>
export HINDSIGHT_API_LLM_2_MODEL=<top-model>
export HINDSIGHT_API_LLM_2_MAX_RETRIES=3

export HINDSIGHT_API_LLM_MAX_RETRIES=0   # fail over at once instead of retrying the same member
export HINDSIGHT_API_LLM_STRATEGY='{"mode": "failover"}'
# Or spread load instead: '{"mode": "round-robin", "weights": [4, 1, 1]}'
```

To give one operation a stronger model, override only that operation's primary. Its chain still falls back to the global members above:

```bash
# Consolidation starts on the mid-tier model; retain and reflect stay on the cheap one
export HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER=openai
export HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY=<openai-key>
export HINDSIGHT_API_CONSOLIDATION_LLM_MODEL=<mid-model>
```

Give an operation a fully separate chain with its own indexed members and strategy (`HINDSIGHT_API_RETAIN_LLM_1_PROVIDER`, `HINDSIGHT_API_RETAIN_LLM_STRATEGY`, ...).

Things to know:

- **Number members from 1 with no gaps.** Hindsight stops reading at the first missing `HINDSIGHT_API_LLM_<n>_PROVIDER`: with `_1_` and `_3_` set, member 3 is ignored.
- **An operation with no members or no strategy of its own uses the global ones.** Mental-model refresh uses the reflect chain first, then the global one.
- **Batch retain does not fail over.** With `HINDSIGHT_API_RETAIN_BATCH_ENABLED=true`, the whole batch runs on the first member whose provider has a batch API. Put a reliable provider there.
- **Startup sends a test call to each configured model.** A model that is down only logs a warning; the server still starts. Set `HINDSIGHT_API_SKIP_LLM_VERIFICATION=true` to skip these checks, for example when fallbacks are paid per call or slow to answer.

---

### GitHub Copilot Setup

Use a GitHub Copilot subscription for Hindsight's extraction, consolidation, and reflection calls through the official GitHub Copilot SDK.

**Prerequisites:**
- An active GitHub Copilot entitlement
- Copilot CLI signed in under the same operating-system user that runs Hindsight

**Configure Hindsight:**

```bash
export HINDSIGHT_API_LLM_PROVIDER=github-copilot
export HINDSIGHT_API_LLM_MODEL=gpt-5.6-terra
# No HINDSIGHT_API_LLM_API_KEY is needed.
```

The provider starts one shared Copilot runtime for all Hindsight LLM lanes. That runtime uses a hook-free temporary `COPILOT_HOME` containing only the signed-in account selection; the credential itself is resolved by the runtime from the system keychain or an existing `gh` CLI login. Each call then uses an isolated transient session with repository instructions, skills, Copilot memory, built-in tools, and the cross-session store disabled. This prevents the memory integration from recursively retaining its own extraction calls.

For automation, the Copilot SDK also accepts `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN`. With one of those set, Copilot CLI never has to have been run on the host, so containers and CI images work with no `~/.copilot` at all. GitHub Actions and server-to-server deployments must have the appropriate Copilot organization policy and token permissions.

**Important notes:**
- Usage counts against the authenticated account or organization's Copilot allowance and AI Credits.
- `HINDSIGHT_API_LLM_BASE_URL` optionally points to an existing headless Copilot runtime, such as `http://127.0.0.1:4321`; it is not an LLM-provider endpoint for this provider.
- GitHub-hosted Copilot sessions do not expose temperature or maximum-output-token controls through the SDK, so those Hindsight settings are not applied.
- Embeddings and reranking continue to use Hindsight's separately configured providers; their local defaults require no API key.

---

### ChatGPT Setup (Sign in with ChatGPT)

The `chatgpt` provider runs Hindsight's LLM calls on your ChatGPT plan through
OpenAI's documented [Sign in with ChatGPT flow for open-source apps](https://developers.openai.com/siwc/token-sharing-open-source).
Hindsight registers itself as its own OAuth client on first sign-in, and every call goes to the public
`https://api.openai.com/v1/responses` endpoint with your OAuth access token. It does not use an API key.

How it differs from `openai-codex`: that provider reuses the Codex CLI's sign-in and calls ChatGPT's private
`chatgpt.com/backend-api/codex` endpoint. `chatgpt` uses the supported route and keeps its own
credentials, so it never shares or rotates the Codex CLI's tokens.

**Prerequisites:**
- A ChatGPT plan that allows plan usage in third-party apps
- A browser on the machine where you run the login

**Setup on a machine with a browser (for example a laptop):**

1. **Sign in:**
   ```bash
   hindsight-admin chatgpt login
   ```
   This opens your browser and waits for the callback on `http://127.0.0.1:1455/auth/callback`
   (`--port` changes the port; `--no-browser` prints the URL instead). Approve the request, including
   use of your ChatGPT plan. The command validates the ID token and the granted scopes, then writes
   `~/.hindsight/chatgpt/credentials.json` (mode `0600`) next to this host's `host_id`.
   If you declined plan usage, run `login --enable-plan-usage` to be asked again.

2. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=chatgpt
   # export HINDSIGHT_API_LLM_MODEL=gpt-6-luna       # the default
   # export HINDSIGHT_API_LLM_REASONING_EFFORT=medium  # sent as reasoning.effort
   ```

3. **Check the stored session** (prints account, client id, scopes and expiry, never token values):
   ```bash
   hindsight-admin chatgpt status
   ```

**Self-hosted server or VM (no browser):** the `127.0.0.1` callback reaches the machine running the
browser, so sign in on your laptop and move the credential file to the server.

1. On the laptop: run `login` as above.
2. Copy the file over SSH and install it on the server with `login --from-file`, as the user that runs Hindsight (add
   `--home <dir>` before `login` if the server sets `HINDSIGHT_API_LLM_CHATGPT_HOME`).
   `--from-file` keeps the server's own `host_id` (creating one on first use) instead of the laptop's:
   ```bash
   scp ~/.hindsight/chatgpt/credentials.json server:/tmp/chatgpt.json
   ssh server 'hindsight-admin chatgpt login --from-file /tmp/chatgpt.json && rm /tmp/chatgpt.json'
   ```
3. On the laptop, stop using that copy without revoking it:
   ```bash
   hindsight-admin chatgpt logout --local-only
   ```
   Refresh tokens rotate, so after the copy only the server may use and refresh this session. A plain
   `logout` would revoke it and end the server's session too. `--local-only` clears the laptop's
   tokens but keeps its client registration for a later `login`. `--from-file` refuses to replace a
   different account's registration on the server unless you pass `--force`.

The server then owns refreshes. Sign in again on the laptop later if you also want Hindsight there.

**Important notes:**
- Access tokens last one hour and are refreshed automatically (five minutes before expiry, and once on
  an HTTP 401). Refresh tokens rotate; refreshes take a file lock and re-read the file first, so
  several Hindsight processes can share one credential directory.
- When the refresh token is no longer valid, calls fail with the `login` command to run.
- On ChatGPT Plus, the five-hour usage limit is shared with every other app that uses your plan,
  including Codex. When the limit is reached, Hindsight defers retain and consolidation work and
  retries it 30 minutes later instead of failing it. Review usage and per-app limits at
  [ChatGPT Settings → Usage](https://chatgpt.com/settings/usage).
- The route is a preview: Hindsight sends `store: false` and `stream: true`, omits fields it rejects
  (`temperature`, `max_output_tokens`, `user`, and others), and sends system prompts as developer
  messages.
- `logout` revokes the session and clears local tokens but keeps the client registration and host id
  for the next `login`. `logout --local-only` clears the tokens without revoking.
- `HINDSIGHT_API_LLM_CHATGPT_HOME` relocates the credential directory. Use one directory per
  ChatGPT account. The directory must be writable, because each refresh writes the rotated token
  back; in a container, mount a writable volume there. If a write fails, the process keeps the new
  tokens in memory, but other processes and restarts will not see them.

### OpenAI Codex Setup (ChatGPT Plus/Pro)

Use your ChatGPT Plus or Pro subscription for Hindsight without separate OpenAI Platform API costs.

**Prerequisites:**
- Active ChatGPT Plus or Pro subscription
- Node.js/npm installed (for Codex CLI)

**Setup Steps:**

1. **Install Codex CLI:**
   ```bash
   npm install @openai/codex
   ```

2. **Login with ChatGPT credentials:**
   ```bash
   codex auth login
   ```
   This opens a browser window to authenticate with your ChatGPT account and saves OAuth tokens to `~/.codex/auth.json`.

3. **Verify authentication:**
   ```bash
   ls ~/.codex/auth.json  # Should show the auth file exists
   ```

4. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=openai-codex
   # export HINDSIGHT_API_LLM_MODEL=gpt-5.6-luna  # the default
   # No API key needed - reads from ~/.codex/auth.json automatically
   ```

5. **Start Hindsight:**
   ```bash
   hindsight-api
   ```

You can use any model supported by OpenAI Codex CLI

**Important Notes:**
- OAuth tokens are stored in `~/.codex/auth.json`
- Tokens refresh automatically when needed
- Usage is billed to your ChatGPT subscription (not separate API costs)
- For personal development use only (see ChatGPT Terms of Service)

#### Isolating Codex auth for long-running services

By default Hindsight reads Codex credentials from `~/.codex/auth.json` — the
same file the `@openai/codex` CLI, editor plugins, and other agent runtimes use.
This is convenient for local development but can cause a subtle failure mode when
Hindsight runs as a **long-lived service** (systemd unit, container, background
daemon) alongside another Codex process:

- Codex refresh tokens are single-use and rotate on refresh.
- If another process refreshes the shared token, Hindsight's long-running
  process is left holding a stale refresh token.
- Recall and `/health` keep working (the database and API are fine), but
  `/reflect` fails with an error such as:
  ```text
  Codex refresh_token is permanently invalid (error.code=refresh_token_reused).
  Run 'codex auth login' to re-authenticate.
  ```

To avoid this, give the Hindsight service its **own dedicated Codex auth home**
via the `CODEX_HOME` environment variable. Hindsight honors `CODEX_HOME` exactly
like the `@openai/codex` CLI: when set, it reads `$CODEX_HOME/auth.json` instead
of `~/.codex/auth.json`.

```bash
# Dedicated credentials directory for the Hindsight service
export CODEX_HOME=/var/lib/hindsight/codex

# One-time login into that isolated home (opens a browser / device-code flow)
codex auth login   # writes $CODEX_HOME/auth.json

export HINDSIGHT_API_LLM_PROVIDER=openai-codex
hindsight-api
```

For a systemd unit, set it in the service definition so it never shares auth
with an interactive Codex session:

```ini
[Service]
Environment=CODEX_HOME=/var/lib/hindsight/codex
```

After a fresh login into the dedicated home and restarting only the Hindsight
service, `/reflect` uses its own token that other Codex processes will not
rotate out from under it.

`CODEX_HOME` is also honored by the `openai-codex` embeddings provider.

#### Two Codex profiles in one process

`CODEX_HOME` is process-wide, so every Codex provider a Hindsight process builds
resolves the same `auth.json`. To run more than one independently authorized
ChatGPT account — for example, to fail over when the preferred account hits its
usage limit — give each one its own credentials directory with
`HINDSIGHT_API_LLM_CODEX_HOME` (primary) and `HINDSIGHT_API_LLM_<n>_CODEX_HOME`
(indexed [multi-LLM chain](./configuration#multi-llm-strategies-failover--round-robin)
members). Each falls back to `CODEX_HOME`, then `~/.codex`, when unset.

```bash
# Two profiles, logged in separately
CODEX_HOME=/var/lib/hindsight/codex-a codex auth login
CODEX_HOME=/var/lib/hindsight/codex-b codex auth login

export HINDSIGHT_API_LLM_PROVIDER=openai-codex
export HINDSIGHT_API_LLM_CODEX_HOME=/var/lib/hindsight/codex-a
export HINDSIGHT_API_LLM_1_PROVIDER=openai-codex
export HINDSIGHT_API_LLM_1_CODEX_HOME=/var/lib/hindsight/codex-b
export HINDSIGHT_API_LLM_STRATEGY='{"mode": "failover"}'
```

Token refresh is coordinated per auth-file path, so the two profiles refresh
independently and never overwrite each other's tokens. Failover is the generic
multi-LLM behaviour: a member is tried after the previous one has exhausted its
own retries and raised — there is no separate quota classifier or cooldown, so a
rate-limited primary is re-tried (and fails) at the head of each request before
the fallback serves it.

---

### Meta Model API Setup

[Meta Model API](https://ai.developer.meta.com) serves the Muse Spark models over an
OpenAI-compatible endpoint. Get a key from the Model API dashboard, then:

```bash
export HINDSIGHT_API_LLM_PROVIDER=meta
export HINDSIGHT_API_LLM_API_KEY=your-meta-model-api-key
export HINDSIGHT_API_LLM_MODEL=muse-spark-1.3
```

The base URL defaults to `https://api.meta.ai/v1`. Available models are
`muse-spark-1.3` (recommended), `muse-spark-1.2`, `muse-spark-1.1`, and the
discounted `-contributor` variants of 1.3 and 1.2, which permit training on your
prompts and completions. All share a 1,048,576-token context window.

#### Required knobs

Muse Spark **always reasons** before it replies. That single property drives every
setting below, so treat these as required rather than optional tuning:

| Variable | Set it to | Why |
|----------|-----------|-----|
| `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` | `300` | Reflect's default is 30s. Muse Spark's final synthesis exceeds that, and the call fails after its retries rather than degrading — reflect returns nothing. |
| `HINDSIGHT_API_LLM_TIMEOUT` | `300` | The global deadline (default 120s) covers retain and consolidation, which are slower here than on a non-reasoning model. |
| `HINDSIGHT_API_LLM_REASONING_EFFORT` | unset, or `minimal`/`low`/`medium`/`high`/`xhigh` | `none` is rejected with `HTTP 400`. Leave it unset to let the model choose its own depth. |
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | leave at the `64000` default | Reasoning tokens are billed against the **output** budget. Lower this too far and a reply comes back with no content at all. |

#### Good to know

- **Prompt caching is automatic.** There is no key, flag, or breakpoint to set — Meta
  reuses a matching prompt prefix on its own, so the capability table below lists no
  explicit prompt-caching support even though the benefit applies.
- **No batch API and no embeddings endpoint.** Embeddings continue to come from
  whichever `HINDSIGHT_API_EMBEDDINGS_PROVIDER` you configure.
- **Recursive JSON schemas are rejected** with `HTTP 400`. No Hindsight code path
  sends one, so this only matters if you add a self-referencing response model.
- **Expect slower calls.** A trivial prompt can spend more tokens reasoning than it
  returns as output.

---

### Nous Portal Setup (Hermes)

Use your [Nous Portal](https://portal.nousresearch.com) subscription for Hindsight via the Hermes CLI login — no static API key required.

**Prerequisites:**
- A Nous Portal account
- The [Hermes](https://hermes-agent.nousresearch.com) CLI installed

**Setup Steps:**

1. **Log in to Nous Portal:**
   ```bash
   hermes portal
   ```
   This opens a browser to authenticate with Nous Portal and saves OAuth credentials to `~/.hermes/auth.json`.

2. **Verify authentication:**
   ```bash
   hermes portal status  # should show "Auth: ✓ logged in"
   ```

3. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=nous
   # export HINDSIGHT_API_LLM_MODEL=deepseek/deepseek-v4-flash  # defaults to deepseek/deepseek-v4-flash
   # No API key needed — reads from ~/.hermes/auth.json automatically
   ```

4. **Start Hindsight:**
   ```bash
   hindsight-api
   ```

You can use any model hosted on the Nous Portal inference API.

**Important Notes:**
- Credentials are read from `~/.hermes/auth.json` (the same store the Hermes agent uses) — no static API key in Hindsight's config.
- The short-lived inference JWT is refreshed automatically, before expiry and reactively on a 401.
- Refreshes coordinate with a running Hermes agent through the shared auth store, so the two never disrupt each other's session.
- Default base URL: `https://inference-api.nousresearch.com/v1` (override with `HINDSIGHT_API_LLM_BASE_URL`).

---

### Claude Code Setup (Claude Pro/Max)

Use your Claude Pro or Max subscription for Hindsight without separate Anthropic API costs.


:::warning Terms of Service Notice

This integration uses the Claude Agent SDK with your personal Claude Pro/Max subscription
credentials. You must be logged into Claude Code on your own machine before using this provider.

**Please be aware:**

- Anthropic's [Agent SDK documentation](https://docs.claude.com/en/api/agent-sdk/overview)
  states that third-party developers should not offer claude.ai login or rate limits for
  their products. Hindsight does **not** perform any login on your behalf — it uses
  credentials you've already authenticated via `claude auth login`.
- In January 2026, Anthropic [enforced restrictions](https://paddo.dev/blog/anthropic-walled-garden-crackdown/)
  against third-party tools using Claude subscription OAuth tokens. Those restrictions
  targeted tools that **spoofed the Claude Code client identity** — Hindsight uses the
  official Claude Agent SDK instead.
- This provider is intended for **local, personal development use only**. Do not use it
  in production deployments or shared environments.
- Anthropic's terms may change. If you want guaranteed compliance, use the `anthropic`
  provider with an API key instead.
- Usage counts against your Claude Pro/Max subscription limits.

For production or team use, we recommend using `HINDSIGHT_API_LLM_PROVIDER=anthropic` with
an API key from the [Anthropic Console](https://console.anthropic.com/).

:::


**Prerequisites:**
- Active Claude Pro or Max subscription
- Claude Code CLI installed

**Setup Steps:**

1. **Install Claude Code CLI:**
   ```bash
   npm install @anthropics/claude-code
   # Or via Homebrew
   brew install anthropics/claude-code/claude-code
   ```

2. **Login with Claude credentials:**
   ```bash
   claude auth login
   ```
   This opens a browser window to authenticate with your Claude account. Authentication is automatically managed by the Claude Agent SDK.

3. **Verify authentication:**
   ```bash
   claude --version
   # Should show version without errors
   ```

4. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=claude-code
   # No API key needed - uses claude auth login credentials
   ```

   **Server-held token (optional, for a personal machine without a browser login):** instead of
   relying on the host's `claude auth login` session, you can generate a long-lived token once and
   give it to Hindsight. This is still subscription access, so the Terms of Service notice above
   applies in full: it does not make the provider suitable for servers, Docker deployments or shared
   hosts. For those, use the `anthropic` provider with an API key.
   ```bash
   claude setup-token            # prints sk-ant-oat01-...
   export HINDSIGHT_API_LLM_API_KEY=sk-ant-oat01-...
   ```
   Hindsight passes it to every spawned CLI as `CLAUDE_CODE_OAUTH_TOKEN`, so the auth session lives
   in the server's config (and can be set per operation or per bank like any other LLM key).
   With no key set, the CLI's own login is used.

5. **Start Hindsight:**
   ```bash
   hindsight-api
   ```

You can use any model supported by Claude Code CLI.

**Important Notes:**
- Authentication handled by Claude Agent SDK (uses bundled CLI)
- Credentials managed securely by Claude Code
- Usage billed to your Claude subscription (not separate API costs)
- For personal development use only (see Claude Terms of Service)


---

### Cursor Setup (Cursor subscription)

Serve Hindsight's extraction, consolidation and reflection calls from a **Cursor
subscription**, by driving the `cursor-agent` CLI in its headless print mode. No API key
and no second per-token billing relationship.

This is the opposite direction from the [Cursor integration](/sdks/integrations/cursor),
which makes Cursor a *client* of Hindsight. Here Cursor is the model backend Hindsight calls.

**Prerequisites:**
- An active Cursor subscription (a free plan works, but only with `auto` — see below)
- `cursor-agent` installed and signed in under the same OS user that runs Hindsight

**Setup Steps:**

1. **Install the CLI:**
   ```bash
   curl https://cursor.com/install -fsS | bash
   ```

2. **Log in:**
   ```bash
   cursor-agent login
   ```

3. **Verify:**
   ```bash
   cursor-agent --version
   cursor-agent --list-models
   ```

4. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=cursor
   export HINDSIGHT_API_LLM_MODEL=auto
   # An agent CLI turn takes 15-30s, well past the 30s reflect default.
   export HINDSIGHT_API_LLM_TIMEOUT=300
   export HINDSIGHT_API_REFLECT_LLM_TIMEOUT=180
   # No API key needed. To authenticate with a key instead:
   # export HINDSIGHT_API_LLM_API_KEY=...   # or CURSOR_API_KEY
   ```

**Important notes:**

- **Raise the timeouts.** Each call spawns a `cursor-agent` turn, which takes 15-30s
  against `auto` — slower than a chat-completions request and past the 30s
  `HINDSIGHT_API_REFLECT_LLM_TIMEOUT` default. Left at the default, reflect spends its
  first iteration on timeout retries before recovering.
- **Structured output and tool calling are prompt-level emulations.** The CLI exposes no
  `response_format`, JSON-schema, temperature or tool-definition flag — only
  `--output-format text|json|stream-json`, which describes the envelope. Hindsight puts
  the schema (or the tool list) in the prompt and parses the model's JSON back out,
  retrying on a parse failure. That is less reliable than a native schema, so prefer a
  strong named model over `auto` for reflect, the operation that leans hardest on tools.
- **Free plans are limited to `auto`.** A named `--model` returns
  `Named models unavailable Free plans can only use Auto`. Paid plans can use any id from
  `cursor-agent --list-models`.
- **The agent's own tools are turned off.** `cursor-agent` is an agent, not a completions
  endpoint: it ships Shell, Read, Write, Delete, web fetch and more, and `--mode ask` is
  *not* a tool switch — a headless run in ask mode will still read files out of its
  working directory. Hindsight therefore runs the CLI in an empty scratch workspace, with
  its own `CURSOR_CONFIG_DIR` (so it never sees your `~/.cursor` config, hooks or session
  history), and writes a `cli-config.json` there that denies every tool by name. That is
  the equivalent of the `tools=[]` the `claude-code` provider passes its SDK, and it
  matters because retain prompts are built from whatever text you store in Hindsight.
- Usage counts against your Cursor subscription.
- Reasoning effort is not supported — the CLI has no such flag, and Hindsight warns once
  at startup if `HINDSIGHT_API_LLM_REASONING_EFFORT` is set.
- Embeddings and reranking continue to use Hindsight's separately configured providers.

---

### SuperGrok Subscription Setup (device-code OAuth)

Serve LLM calls from a **SuperGrok subscription** (device-code OAuth) — no API
key. For API-key access to `api.x.ai`, use `provider: openai` with
`HINDSIGHT_API_LLM_BASE_URL=https://api.x.ai/v1`; **this provider is the
subscription lane**, the same category as `openai-codex` (ChatGPT subscription)
and `claude-code` (Claude subscription). Both routes reach the same published
endpoint, so the credential is the whole difference: a flat-rate consumer
subscription authorized once in a browser, instead of a per-token metered key.

Authentication is xAI's own OIDC issuer at `https://auth.x.ai` using the RFC
8628 device-code flow, with the public OAuth client id published in xAI's
Apache-2.0 Grok CLI sources. Hindsight keeps its own credential file and never
reads or writes the Grok CLI's `~/.grok/auth.json`.

:::note Subscription entitlement

xAI may restrict `api.x.ai` access by SuperGrok subscription tier: an OAuth
grant can be valid and still be refused with HTTP 403. Hindsight reports that
as a distinct entitlement error naming the cause rather than as a credential
failure, and it neither re-tries nor discards the token. If you hit it, verify
the account tier or use an API-key provider instead.

Usage counts against your SuperGrok subscription limits. When the account's
spending limit stops a call, Hindsight raises a distinct quota error and leaves
the credential alone.

:::

**Prerequisites:**
- An active SuperGrok subscription
- A browser on the machine you run the login from (the device-code flow needs
  an interactive approval; the running service never performs one)

**Setup steps:**

1. **Log in once, on the host that will own the credential:**
   ```bash
   python -m hindsight_api.engine.providers.xai_oauth_auth login
   ```
   The command prints a verification URL and a user code, waits for approval,
   then writes `~/.hindsight/xai_oauth.json` with owner-only permissions.

2. **Configure Hindsight:**
   ```bash
   export HINDSIGHT_API_LLM_PROVIDER=xai-oauth
   # export HINDSIGHT_API_LLM_MODEL=grok-4.5   # defaults to grok-4.5
   # No API key needed
   ```

3. **Start Hindsight:**
   ```bash
   hindsight-api
   ```

**Important notes:**
- The access token is refreshed automatically: proactively 60 seconds before
  expiry (or before the configured request timeout, whichever is longer), and
  once reactively on an HTTP 401. Several configured lanes share one credential
  file safely — the refresh takes an advisory lock and re-reads the file under
  it, so siblings do not each refresh.
- A running service **never** starts the device-code flow. When only a login can
  restore service, calls fail with the exact command to run.
- Unlike reaching `api.x.ai` through `provider: openai`, this provider sends the
  member's configured `reasoning_effort`, so that setting takes effect here.
  The one value not forwarded is `none`: xAI rejects it outright (HTTP 400),
  so this lane expresses it the way the API expects — by omitting the field.
- Only non-streaming calls are implemented. Streaming responses omit
  `prompt_tokens_details`, so cached-token accounting would read low.

**Optional environment overrides:**

| Variable | Purpose | Default |
|---|---|---|
| `HINDSIGHT_API_XAI_OAUTH_BASE_URL` | Point at a different upstream. Takes precedence over `HINDSIGHT_API_LLM_BASE_URL`. | `https://api.x.ai/v1` |
| `HINDSIGHT_API_XAI_OAUTH_TOKEN_PATH` | Relocate the credential store. Both the read and the write follow it. | `~/.hindsight/xai_oauth.json` |
| `HINDSIGHT_API_XAI_OAUTH_CLIENT_ID` | Override the OAuth client id used for login and refresh. | xAI's published public client id |
| `HINDSIGHT_API_XAI_OAUTH_SCOPE` | Override the scope string requested at login. | `openid profile email offline_access grok-cli:access api:access` |
| `HINDSIGHT_API_XAI_OAUTH_REFRESH_SKEW_SECONDS` | Refresh this many seconds before expiry. Widen it for deployments that touch the provider rarely. | `60` |
| `HINDSIGHT_API_XAI_OAUTH_REFRESH_TIMEOUT_SECONDS` | Per-request timeout for discovery, login and refresh calls. | `20` |

#### Deployment note

The credential is a host-local file, and only the interactive login can create
it. A containerized engine therefore needs the store mounted from a host where
the login was run (or the login run inside the container once, interactively)
and `HINDSIGHT_API_XAI_OAUTH_TOKEN_PATH` pointed at it. The mount must be
**writable**: unlike the Grok CLI's own file, this store is where Hindsight
persists each rotated token, so a read-only mount degrades to failing once the
current token expires.

xAI issues a **new refresh token on every refresh** and retires the one it
replaces. Replicas must therefore share the one credential file rather than
each starting from its own copy of a single login's output: a copy stops
working as soon as another replica refreshes, and the login has to be repeated.
A single shared writable volume is the supported shape; if you cannot provide
one, run one replica on this provider and give the others an API-key lane.

---

### Azure OpenAI Setup

Azure OpenAI is reached through the **`openai`** provider — there is no `azure`
provider, and setting one fails at startup with
`Invalid LLM provider: azure`.

The one thing that trips people up is the base URL. Azure does not serve the
OpenAI API at the resource root, so the endpoint shown in the Azure portal is
not usable on its own:

| `HINDSIGHT_API_LLM_BASE_URL` | Result |
|---|---|
| `https://<resource>.openai.azure.com` | `404 Resource not found` |
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>` | `404 Resource not found` (no `api-version`) |
| `https://<resource>.openai.azure.com/openai/v1` | works |
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=<version>` | works |

**Recommended — the v1 surface:**

```bash
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=<azure-openai-resource-key>
export HINDSIGHT_API_LLM_MODEL=<deployment-name>
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/v1
```

**Or the deployment-scoped form.** Keep the `api-version` query string — Hindsight
parses it out of the base URL and passes it to the SDK:

```bash
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=2025-01-01-preview
```

**Important notes:**
- `HINDSIGHT_API_LLM_MODEL` is your **deployment name**, not the model name. A
  `gpt-4o` deployed as `my-gpt4o` is configured as `my-gpt4o`.
- The key is the Azure OpenAI **resource** key (`az cognitiveservices account
  keys list -n <resource> -g <group>`). An API Management subscription key is a
  different credential: with APIM in front, the base URL must be the APIM route
  and APIM has to forward the `api-key` header. Test against the Azure endpoint
  directly first to isolate which layer is failing.
- Gateways and proxies must preserve the same path shape (`/openai/v1` or
  `/openai/deployments/...?api-version=`).
- Azure OpenAI accepts `prompt_cache_key` on GPT deployments (it improves cache
  routing on GPT-5.6 and later), but the same `*.openai.azure.com` endpoint also
  serves non-OpenAI Foundry models (DeepSeek, Llama, Mistral) that reject it with
  `unrecognized_request_argument`. The host alone can't tell the two apart, so
  [`HINDSIGHT_API_LLM_CACHE_AFFINITY`](./configuration#llm-provider) resolves
  `auto` to `none` for Azure hosts. If your deployment serves an OpenAI model,
  set it explicitly to `openai_prompt_cache_key`.

---

### Vertex AI Setup (Google Cloud)

Google Cloud's Vertex AI provides access to Gemini models via the native Google GenAI SDK.

**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-3.1-flash-lite
   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-3.1-flash-lite
   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-3.1-flash-lite`) — it will be stripped automatically
- The native SDK handles token refresh automatically
- Uses service account credentials if provided, otherwise falls back to ADC

---

## Embedding Model

Converts text into dense vector representations for semantic similarity search.

**Default:** `BAAI/bge-small-en-v1.5` (384 dimensions, ~130MB)

### Supported Providers

| Provider | Description | Best For |
|----------|-------------|----------|
| `local` | SentenceTransformers (default) | Development, low latency |
| `onnx` | In-process ONNX Runtime embedder (no Ollama/TEI/API sidecar) | Lightweight local CPU, multilingual |
| `openai` | OpenAI embeddings API | Production, high quality |
| `openai-codex` | OpenAI embeddings via Codex OAuth (ChatGPT Plus/Pro, no API key) | Existing ChatGPT/Codex subscribers |
| `openrouter` | OpenRouter embeddings (OpenAI-compatible gateway) | Multi-provider setups |
| `cohere` | Cohere embeddings API | Production, multilingual |
| `google` | Google embeddings (Gemini API or Vertex AI) | Production, multilingual, high quality |
| `tei` | HuggingFace Text Embeddings Inference | Production, self-hosted |
| `zeroentropy` | ZeroEntropy zembed-1 | Production, high quality retrieval |
| `nvidia` | NVIDIA NIM retrieval embeddings (`input_type`-aware) | Users on NVIDIA NIM / build.nvidia.com |
| `litellm` | LiteLLM proxy (unified gateway) | Multi-provider setups |
| `litellm-sdk` | LiteLLM SDK (direct API, no proxy) | Multi-provider, simpler setup |

### Local Models

| Model | Dimensions | Use Case |
|-------|------------|----------|
| `BAAI/bge-small-en-v1.5` | 384 | Default, fast, good quality |
| `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | 384 | Multilingual (50+ languages) |

### OpenAI Models

| Model | Dimensions | Use Case |
|-------|------------|----------|
| `text-embedding-3-small` | 1536 | Default OpenAI, cost-effective |
| `text-embedding-3-large` | 3072 | Higher quality, more expensive |
| `text-embedding-ada-002` | 1536 | Legacy model |

### Google Models

| Model | Dimensions | Use Case |
|-------|------------|----------|
| `gemini-embedding-001` | 768 (configurable) | Default Google, general purpose |
| `gemini-embedding-2-preview` | 768 (configurable) | Gemini Embedding 2 family; multimodal, one vector per input |

Google's `gemini-embedding-001` supports configurable output dimensionality via truncation, google recommend using: 768, 1536, 3072, via `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY`. Default is 768.

The `gemini-embedding-2` family, including `gemini-embedding-2-preview`, is supported on both the Gemini API and Vertex AI. These models aggregate multi-input requests, so Hindsight automatically embeds one input per call to keep per-fact vectors aligned.

### Cohere Models

| Model | Dimensions | Use Case |
|-------|------------|----------|
| `embed-english-v3.0` | 1024 | English text |
| `embed-multilingual-v3.0` | 1024 | 100+ languages |

### ZeroEntropy Models

| Model | Dimensions | Use Case |
|-------|------------|----------|
| `zembed-1` | 1280 default (2560/1280/640/320/160/80/40 configurable) | High quality asymmetric retrieval |

Hindsight sends retained memory text to ZeroEntropy as `document` inputs and recall/search text as `query` inputs. ZeroEntropy's API default is 2560 dimensions; Hindsight defaults to 1280 so pgvector HNSW works without changing the vector extension.

:::warning Embedding Dimensions
Hindsight automatically detects the embedding dimension at startup and adjusts the database schema. Once memories are stored, you cannot change dimensions without losing data.
:::

**Configuration Examples:**

```
/* Detailed source-code truncated for AI context efficiency. */
```

:::tip AWS Bedrock application inference profiles

If your org's Service Control Policy denies `bedrock:InvokeModel` on the bare model id once an [application inference profile](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html) exists, keep `..._MODEL` as-is and add the profile ARN separately:

```bash
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm-sdk
# Stays a recognizable model id — this is what picks the request format
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL=bedrock/amazon.titan-embed-text-v2:0
# The profile actually invoked
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL_ID=arn:aws:bedrock:eu-west-1:123456789012:application-inference-profile/abc123
```

The two are separate because Bedrock's embedding request and response formats differ per model family (Titan, Cohere, TwelveLabs, Nova), and the family is read off the model id. A profile ARN is opaque and account-scoped, so it can't be used for that — put it in `..._MODEL_ID` and leave `..._MODEL` naming the real model.

Chat models don't need this: set `HINDSIGHT_API_LLM_MODEL=bedrock/converse/<arn>`, since the Converse API takes one format for every model.

:::

See [Configuration](./configuration#embeddings) for all options including Azure OpenAI and custom endpoints.

For a runnable Docker Compose setup that serves both embeddings and reranking from self-hosted TEI sidecars, see [`docker/docker-compose/tei/`](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/tei).

---

## Cross-Encoder (Reranker)

Reranks initial search results to improve precision.

**Default:** `cross-encoder/ms-marco-MiniLM-L-6-v2` (~85MB)

### Supported Providers

| Provider | Description | Best For |
|----------|-------------|----------|
| `local` | SentenceTransformers CrossEncoder (default) | Development, low latency |
| `cohere` | Cohere rerank API | Production, high quality |
| `openrouter` | OpenRouter rerank API (Cohere-compatible gateway) | Multi-provider setups |
| `zeroentropy` | ZeroEntropy rerank API (zerank-2) | Production, state-of-the-art accuracy |
| `siliconflow` | SiliconFlow rerank API (Cohere-compatible `/rerank` endpoint) | Users in China or anyone on SiliconFlow's platform |
| `nvidia` | NVIDIA NIM reranking API (native `/retrieval/{model}/reranking` endpoint) | Users on NVIDIA NIM / build.nvidia.com |
| `typesafe` | TypeSafe typed-decision API (Jev) — ranks the whole pool in one question | Production, highest ranking quality; can also return only the relevant candidates |
| `alibaba` | Alibaba Cloud DashScope rerank API (qwen3-rerank) | Users on Alibaba Cloud / DashScope |
| `google` | Google Discovery Engine ranking API (REST + Google auth) | Production, GCP integration |
| `tei` | HuggingFace Text Embeddings Inference | Production, self-hosted |
| `flashrank` | FlashRank (lightweight, fast) | Resource-constrained environments |
| `onnx` | Any cross-encoder exported to ONNX, in-process via ONNX Runtime | Custom models without PyTorch or a TEI server |
| `litellm` | LiteLLM proxy (unified gateway) | Multi-provider setups |
| `litellm-sdk` | LiteLLM SDK (direct API, no proxy) | Multi-provider, simpler setup |
| `jina-mlx` | Jina rerank v3 via Apple Silicon MLX (local, no API key) | Apple Silicon (M1+) local inference |
| `rrf` | RRF-only (no neural reranking) | Testing, minimal resources |

### Local Models

| Model | Use Case |
|-------|----------|
| `cross-encoder/ms-marco-MiniLM-L-6-v2` | Default, fast |
| `cross-encoder/ms-marco-MiniLM-L-12-v2` | Higher accuracy |
| `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | Multilingual |

### Cohere Models

| Model | Use Case |
|-------|----------|
| `rerank-english-v3.0` | English text |
| `rerank-multilingual-v3.0` | 100+ languages |

### ZeroEntropy Models

| Model | Use Case |
|-------|----------|
| `zerank-2` | Flagship multilingual reranker (default) |
| `zerank-2-small` | Faster, lighter variant |

### SiliconFlow Models

SiliconFlow hosts a range of open-weight rerankers behind a Cohere-compatible `/rerank` endpoint:

| Model | Use Case |
|-------|----------|
| `BAAI/bge-reranker-v2-m3` | Multilingual, strong default |
| `Qwen/Qwen3-Reranker-8B` | Larger, higher accuracy |

### TypeSafe Models

TypeSafe is not a `/rerank` endpoint — it answers typed *questions* against a *state*.
Hindsight makes the candidates the options of a Choice question, so the returned
probability distribution is the ranking. When the candidate pool fits within the 250-option
limit and context token budget, a single call ranks the entire pool. Larger pools or pools
with long documents are partitioned into groups whose top candidates advance to a finals
ranking round, with token truncation applied to fit context windows.

| Model | Use Case |
|-------|----------|
| `jev-latest` | Default; tracks the current Jev release |

Setting `HINDSIGHT_API_RERANKER_TYPESAFE_PRUNE_CANDIDATES=true` adds a second question
that cuts the ranked list where relevance ends, so recall returns the relevant
candidates and nothing else. It is off by default because it meaningfully shrinks what
recall returns. See [Configuration](/developer/configuration#typesafe) for the full
behaviour and its trade-offs.

TypeSafe's scores are rank positions within one recall, not relevance scores, so recall
publishes no `scores.reranker` under it and rejects `min_scores.reranker` with HTTP 400.

### Alibaba Cloud Models

Alibaba Cloud DashScope exposes `qwen3-rerank` via a Cohere-compatible `/reranks` endpoint:

| Model | Use Case |
|-------|----------|
| `qwen3-rerank` | 100+ languages, default |

### LiteLLM Supported Providers

LiteLLM supports multiple reranking providers via the `/rerank` endpoint:

| Provider | Model Example |
|----------|---------------|
| Cohere | `cohere/rerank-english-v3.0` |
| Together AI | `together_ai/...` |
| Voyage AI | `voyage/rerank-2` |
| Jina AI | `jina_ai/...` |
| AWS Bedrock | `bedrock/...` |

**Configuration Examples:**

```
/* Detailed source-code truncated for AI context efficiency. */
```

See [Configuration](./configuration#reranker) for all options including Azure-hosted endpoints and batch settings.

---

### Hindsight Docs Docs Developer Monitoring (`hindsight-docs/docs/developer/monitoring.md`)

# Monitoring

Hindsight provides comprehensive observability through Prometheus metrics, OpenTelemetry distributed tracing, and pre-built Grafana dashboards.

## Local Development

For local observability, use the Grafana LGTM (Loki, Grafana, Tempo, Mimir) all-in-one stack:

```bash
./scripts/dev/start-monitoring.sh
```

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 stack is for development only. In production, deploy Grafana LGTM separately or use commercial platforms (Grafana Cloud, DataDog, New Relic, etc.).
:::

## Grafana Dashboards

Pre-built dashboards are available in [`monitoring/grafana/dashboards/`](https://github.com/anthropics/hindsight/tree/main/monitoring/grafana/dashboards). Import these JSON files into your Grafana instance:

| Dashboard | Description |
|-----------|-------------|
| **Hindsight Operations** | Operation rates, latency percentiles, per-bank metrics |
| **Hindsight LLM Metrics** | LLM calls, token usage, latency by scope/provider |
| **Hindsight API Service** | HTTP requests, error rates, DB pool, process metrics |

The dashboards are automatically provisioned when using the monitoring stack script.

## Metrics Endpoint

Hindsight exposes Prometheus metrics at `/metrics`:

```bash
curl http://localhost:8888/metrics
```

## Health Endpoints

The API server (port 8888) and every worker (port 8889) expose the same three
endpoints. They answer two different questions, and pointing a probe at the wrong
one is the difference between riding out a database blip and turning it into an
outage:

| Endpoint | Checks the database? | Use for |
|----------|----------------------|---------|
| `/health/live` | No | Liveness probes |
| `/health/ready` | Yes | Readiness probes |
| `/health` | Yes | Readiness — alias of `/health/ready`, kept for compatibility |

**`/health/live`** returns 200 whenever the process can serve a request, and does
no I/O to get there:

```json
{ "status": "alive", "version": "0.4.0", "uptime_seconds": 812.4 }
```

Answering at all is the check. Hindsight runs request handlers and task work on a
single event loop, so a loop wedged by a blocking call cannot respond inside the
probe timeout — which is exactly the failure a restart fixes. The worker's payload
adds `worker_id`, `is_shutdown`, and `seconds_since_last_poll` (the age of its last
completed claim cycle, `null` before the first one). That last field is there to
alert on: it never changes the status code, because a poller stalled behind a
saturated database is the case where restarting makes things worse.

**`/health` and `/health/ready`** acquire a pooled connection and run `SELECT 1`,
returning 200 when the database is reachable and 503 when it is not. The payload
separates the two ways that can go wrong — `db_acquire_ms` and `db_pool_waiting`
point at pool exhaustion, a slow query at the database itself:

```json
{ "status": "healthy", "database": "connected", "db_acquire_ms": 0.4, "db_pool_waiting": 0 }
```

:::warning Never point a liveness probe at a dependency check
A liveness failure means "restart this process." If your liveness probe checks the
database, then a slow database restarts every pod at once: in-flight requests are
dropped, claimed async operations are requeued with `retry_count` incremented
toward the permanent-failure cliff, and each restarted pod reconnects to re-warm
its pool against a database that is already struggling. Readiness failing is the
correct response — it takes the pod out of the Service and puts it back when the
database recovers.

The bundled Helm chart is wired this way already (`livenessProbe` → `/health/live`,
`readinessProbe` → `/health`). If you wrote your own manifests against an older
version, move the liveness path over.
:::

## Available Metrics

:::note High-cardinality labels are opt-in
`bank_id` and `tenant` (the tenant schema) are left off every metric by default, because each
one adds a series set per bank or tenant. Turn them on with
`HINDSIGHT_API_METRICS_INCLUDE_BANK_ID=true` / `HINDSIGHT_API_METRICS_INCLUDE_TENANT=true`
only on deployments with few banks or tenants. The backlog gauges always carry `tenant`.
:::

### Operation Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.operation.duration` | Histogram | operation, bank_id, source, budget, max_tokens, success, memories_backend | Duration of operations in seconds |
| `hindsight.operation.total` | Counter | operation, bank_id, source, budget, max_tokens, success, memories_backend | Total number of operations executed |

**Labels:**
- `operation`: Operation type (`retain`, `recall`, `reflect`, plus async worker task types such as `consolidation`)
- `bank_id`: Memory bank identifier
- `source`: Where the operation was triggered from (`api`, `reflect`, `internal`, `worker`)
- `budget`: Budget level if specified (`low`, `mid`, `high`)
- `max_tokens`: Max tokens if specified
- `success`: Whether the operation succeeded (`true`, `false`)
- `memories_backend`: The store serving the bank, when the memories extension names one
  (`MemoriesExtension.backend_name_for`). Absent by default, so existing series are unchanged; a
  deployment whose banks live in different stores uses it to compare their latency without
  turning on `tenant`. Recall phase metrics recorded inside the operation carry it too.

The `source` label allows distinguishing between:
- `api`: Direct API calls from clients
- `reflect`: Internal recall calls made during reflect operations
- `internal`: Other internal operations
- `worker`: Async worker completions recorded when a claimed task reaches a terminal outcome

For `source="worker"`, the `success` label is a completion-throughput signal:
`false` means the task raised out to the poller after retries were exhausted or
an unexpected error occurred. Failures handled inside the executor and returned
normally still record `success="true"` here; use
`hindsight_async_operations{status="failed"}` for authoritative async operation
failure status.

### Retain Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.retain.documents.total` | Counter | outcome, bank_id | Documents processed by retain, by extraction outcome |

**Labels:**
- `outcome`: `facts` when the document has memory units after the retain, `no_facts` when it has none
- `bank_id`: Memory bank identifier

`outcome="no_facts"` is the signal that a document was stored but produced no memories. Those documents are invisible to `recall` and `reflect` until they are [reprocessed](/developer/retain#when-a-mission-excludes-everything-in-a-document) — nothing else reports them, because the retain itself succeeded. A rising share usually means a [retain mission](/developer/retain#steering-extraction-with-a-mission) is excluding more than intended:

```promql
sum(rate(hindsight_retain_documents_total{outcome="no_facts"}[15m]))
  / sum(rate(hindsight_retain_documents_total[15m]))
```

### LLM Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.llm.duration` | Histogram | provider, model, scope, success | Duration of LLM API calls in seconds |
| `hindsight.llm.calls.total` | Counter | provider, model, scope, success | Total number of LLM API calls |
| `hindsight.llm.tokens.input` | Counter | provider, model, scope, success, token_bucket | Input tokens for LLM calls |
| `hindsight.llm.tokens.output` | Counter | provider, model, scope, success, token_bucket | Output tokens from LLM calls |

**Labels:**
- `provider`: LLM provider (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `bedrock`, `litellm`)
- `model`: Model name (e.g., `gpt-4`, `claude-3-sonnet`)
- `scope`: What the LLM call is for (`memory`, `reflect`, `consolidation`, `answer`)
- `success`: Whether the call succeeded (`true`, `false`)
- `token_bucket`: Token count bucket for cardinality control (`0-100`, `100-500`, `500-1k`, `1k-5k`, `5k-10k`, `10k-50k`, `50k+`)

### Consolidation Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.consolidation.batch_failures` | Counter | failure_class, error_type | Consolidation LLM batch calls that failed, including those whose facts were later recovered |

**Labels:**
- `failure_class`: How the call was treated (`fail_fast` — the model returned something the response schema rejects, so a re-send of the same payload cannot help; `retry` — transport-shaped, an unchanged re-send may succeed; `propagate` — not a batch failure, re-raised to the task handler)
- `error_type`: Exception class name (e.g. `ValidationError`, `JSONDecodeError`)

This is not the same signal as the `failed_consolidation` field in bank stats. That
field counts *facts* still waiting to be consolidated after a failure; when a batch
call fails, consolidation halves the batch and retries, so a call that failed at
batch size 8 and succeeded at size 1 leaves `failed_consolidation` at 0. Everything
the failed response asked for is dropped, though — including any observations it
wanted to delete — so a bank can look completely healthy while its
supersession cleanup does nothing.

Alert on the schema-rejection rate, which no other metric exposes:

```promql
sum(rate(hindsight_consolidation_batch_failures_total{failure_class="fail_fast"}[15m]))
```

A sustained non-zero rate usually means the consolidation model does not reliably
emit valid JSON for the response schema. Switching to a model with stronger
structured output, or enabling `HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION` if
the provider supports grammar-enforced schemas, is the usual fix.

The same count is reported per run as `llm_batch_failures` in the consolidation
operation's result, and the consolidation log summary prints a warning line
whenever it is non-zero. Both count *attempts* — one batch call retried three times
contributes three — so they are not bounded by the number of batches in the run.

### HTTP Request Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.http.duration` | Histogram | method, endpoint, status_code, status_class | Duration of HTTP requests in seconds |
| `hindsight.http.requests.total` | Counter | method, endpoint, status_code, status_class | Total number of HTTP requests |
| `hindsight.http.requests.in_progress` | UpDownCounter | method, endpoint | Number of HTTP requests currently being processed |

**Labels:**
- `method`: HTTP method (`GET`, `POST`, `PUT`, `DELETE`)
- `endpoint`: Template of the route the request matched, with path parameters named rather than filled in (e.g. `/v1/default/banks/{bank_id}/documents/{document_id}`, `/v1/default/banks/{bank_id}/operations/{operation_id}`), so the label has one value per route however many banks, documents or ids are requested. The template does not include a `base_path` / root-path prefix. Requests that match no route (404s) are labelled `unmatched`; a request matched by a route that has no path template is labelled `unknown_route`.
- `status_code`: HTTP status code (`200`, `400`, `500`, etc.)
- `status_class`: Status code class (`2xx`, `4xx`, `5xx`)

### Database Pool Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.db.pool.size` | Gauge | - | Current number of connections in the pool |
| `hindsight.db.pool.idle` | Gauge | - | Number of idle connections in the pool |
| `hindsight.db.pool.min` | Gauge | - | Minimum pool size |
| `hindsight.db.pool.max` | Gauge | - | Maximum pool size |

### Process Metrics

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `hindsight.process.cpu.seconds` | Gauge | type | Process CPU time in seconds |
| `hindsight.process.memory.bytes` | Gauge | type | Process memory usage in bytes |
| `hindsight.process.open_fds` | Gauge | - | Number of open file descriptors |
| `hindsight.process.threads` | Gauge | - | Number of active threads |

**Labels:**
- `type` (CPU): `user` or `system`
- `type` (Memory): `rss_max` (maximum resident set size)

### Histogram Buckets

Custom bucket boundaries are configured for better percentile accuracy:

**Operation Duration Buckets (seconds):**
```
0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0
```

**LLM Duration Buckets (seconds):**
```
0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0
```

**HTTP Duration Buckets (seconds):**
```
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0
```

## Prometheus Configuration

```yaml
scrape_configs:
  - job_name: 'hindsight'
    static_configs:
      - targets: ['localhost:8888']
```

## Example Queries

### Average operation latency by type
```promql
rate(hindsight_operation_duration_sum[5m]) / rate(hindsight_operation_duration_count[5m])
```

### LLM calls per minute by provider
```promql
rate(hindsight_llm_calls_total[1m]) * 60
```

### P95 LLM latency
```promql
histogram_quantile(0.95, rate(hindsight_llm_duration_bucket[5m]))
```

### Total tokens consumed by model
```promql
sum by (model) (hindsight_llm_tokens_input_total + hindsight_llm_tokens_output_total)
```

### Internal vs API recall operations
```promql
sum by (source) (rate(hindsight_operation_total{operation="recall"}[5m]))
```

### HTTP requests per second by endpoint
```promql
sum by (endpoint) (rate(hindsight_http_requests_total[1m]))
```

### HTTP error rate (5xx)
```promql
sum(rate(hindsight_http_requests_total{status_class="5xx"}[5m])) / sum(rate(hindsight_http_requests_total[5m]))
```

### P95 HTTP latency
```promql
histogram_quantile(0.95, sum by (le) (rate(hindsight_http_duration_seconds_bucket[5m])))
```

### Database pool utilization
```promql
hindsight_db_pool_size / hindsight_db_pool_max
```

### Active database connections
```promql
hindsight_db_pool_size - hindsight_db_pool_idle
```

### CPU usage rate
```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, [Pydantic Logfire](https://logfire.pydantic.dev), 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

### Trace Context Propagation

Hindsight participates in your existing traces rather than starting parallel ones. When a caller sends W3C trace context (the standard `traceparent` header, which most OpenTelemetry HTTP client instrumentations add automatically), Hindsight continues that trace: the API request appears as a server span under the caller, and every memory operation and LLM call it triggers nests beneath it.

Requests that arrive without trace context still start their own trace, so nothing changes for un-instrumented callers.

Health and metrics endpoints are excluded from tracing so probe traffic doesn't drown out real work. Set `OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` to a comma-separated list of URL patterns to override this.

### Worker Processes

Standalone worker processes honour the same `HINDSIGHT_API_OTEL_*` variables as the API and export their own spans. This matters for deployments that run dedicated workers, since consolidation, background retain and mental model refresh — most of the long-running work and token spend — happen there.

Give the worker its own `HINDSIGHT_API_OTEL_SERVICE_NAME` to separate it from the API in your tracing backend. When left unset, workers report themselves as `hindsight-worker`.

Worker spans are currently their own traces: a background operation is not linked to the request that queued it, because it runs long after that request has returned.

---

### Hindsight Docs Docs Developer Multilingual (`hindsight-docs/docs/developer/multilingual.mdx`)

---
sidebar_position: 5
---

import CodeSnippet from '@site/src/components/CodeSnippet';
import multilingualPy from '!!raw-loader!@site/examples/api/multilingual.py';
import Figure from '@site/src/components/Figure';
import multilingualFigure from '@site/figures/multilingual.json';

# Multilingual Support

Hindsight automatically detects the language of your input and responds in the same language. This means facts, entities, and reflect responses are preserved in their original language without translation to English.

## How It Works

<Figure doc={multilingualFigure} />

When you retain content or reflect on a query, Hindsight:

1. **Detects the input language** automatically from the content
2. **Extracts facts in the original language** - preserving nuance and meaning
3. **Stores entities in their native script** - 张伟 stays 张伟, not "Zhang Wei"
4. **Responds in the same language** - queries in Chinese get Chinese answers

---

## Retain with Non-English Content

When you retain content in any language, Hindsight extracts and stores facts in that same language.

### Example: Chinese Content

<CodeSnippet code={multilingualPy} section="retain-chinese" language="python" />

### Example: Japanese Content

<CodeSnippet code={multilingualPy} section="retain-japanese" language="python" />

---

## Reflect with Non-English Queries

The `reflect` operation also respects the input language, generating thoughtful responses in the same language as the query.

### Example: Chinese Reflection

<CodeSnippet code={multilingualPy} section="reflect-chinese" language="python" />

---

## Mixed Language Content

Hindsight handles mixed-language content gracefully, preserving both languages where appropriate.

### Example: Chinese Text with English Company Names

<CodeSnippet code={multilingualPy} section="mixed-language" language="python" />

---

## Supported Languages

**Hindsight's multilingual support depends entirely on your LLM's language capabilities.** Hindsight instructs the LLM to detect the input language and respond in that same language. If your LLM supports a language, Hindsight will work with it.

Most modern LLMs (GPT-4, Claude, Gemini, Llama 3, etc.) support dozens of languages including:

- **East Asian**: Chinese (Simplified/Traditional), Japanese, Korean
- **European**: Spanish, French, German, Italian, Portuguese, Dutch, Polish, Russian
- **Middle Eastern**: Arabic, Hebrew, Turkish
- **South Asian**: Hindi, Bengali, Tamil
- **Southeast Asian**: Thai, Vietnamese, Indonesian

**To verify support for your target language**, test your LLM directly with content in that language. If the LLM can understand and generate text in the language, Hindsight will preserve it correctly.

---

## Configuring for Multilingual Use

For optimal multilingual performance, configure all four components of the pipeline:

### 1. LLM (Required)
Your LLM must support the target languages. Most modern LLMs do, but verify with your specific model.

### 2. Embedding Model (Recommended)
The default embedding model (`BAAI/bge-small-en-v1.5`) is **English-only**. For multilingual content, use a multilingual embedding model:

```bash
# In your .env file
HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-m3
```

**Recommended multilingual embedding models:**
| Model | Languages | Notes |
|-------|-----------|-------|
| `BAAI/bge-m3` | 100+ | Best overall multilingual performance |
| `intfloat/multilingual-e5-large` | 100+ | Good alternative |
| `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | 50+ | Lighter weight |

### 3. Reranker Model (Recommended)
The default reranker (`cross-encoder/ms-marco-MiniLM-L-6-v2`) is **English-only**. For multilingual content, use a multilingual reranker:

```bash
# In your .env file
HINDSIGHT_API_RERANKER_LOCAL_MODEL=BAAI/bge-reranker-v2-m3
```

**Recommended multilingual reranker models:**
| Model | Languages | Notes |
|-------|-----------|-------|
| `BAAI/bge-reranker-v2-m3` | 100+ | Best multilingual reranking |
| `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | 14 | Lighter alternative |

### 4. BM25 / Full-Text Search Backend

The semantic (embedding) arm covers cross-lingual matches by meaning. Hindsight runs a BM25 keyword arm in parallel, and **BM25 is inherently within-language** — it's character/token matching against a tokenizer's lexemes. The default `native` backend uses PostgreSQL's English dictionary, which produces poor results for non-English content (and no useful tokenization at all for Chinese / Japanese / Korean, which lack whitespace word boundaries).

There are two knobs that interact:

- `HINDSIGHT_API_TEXT_SEARCH_EXTENSION` — selects the backend (`native`, `vchord`, `pg_textsearch`, `pgroonga`, or `pg_search`).
- `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE` — selects the PostgreSQL dictionary used by the `native` backend (default: `english`).

Pick the backend based on the languages your bank stores:

| Backend | Multilingual / CJK | Notes |
|---------|--------------------|-------|
| `native` | European languages only (English, French, German, Spanish, Italian, Portuguese, Russian, Dutch, Swedish, Norwegian, Danish, Finnish, Hungarian, Turkish, Arabic, plus `simple`). CJK requires a third-party dictionary like `zhparser`. | Stock PostgreSQL — no extra extensions. Configure the language via `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`. |
| `vchord` | Multilingual via `llmlingua2` tokenizer. | Best when you're already using vchord for vector search. |
| `pg_textsearch` | English only (hardcoded). | Industry-standard BM25 ranking + Block-Max WAND. |
| `pgroonga` | **Yes — out of the box.** Single index handles English, CJK, and mixed-script content via the `TokenBigram` polyglot tokenizer + `NormalizerNFKC150` Unicode normalization. | Recommended for non-English / mixed-language banks. Requires the `pgroonga` extension. See `docker/docker-compose/pgroonga/`. |
| `pg_search` | Multilingual via configurable tokenizer (e.g. `chinese_compatible`, `jieba`, `chinese_lindera`, `japanese_lindera`, `korean_lindera`, `ngram`). | ParadeDB `pg_search` extension; the only Citus-compatible BM25 backend. Tokenizer set via `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER`. See `docker/docker-compose/pg_search/`. |

**Choosing for a single-language bank** (e.g. all Spanish content):
```bash
HINDSIGHT_API_TEXT_SEARCH_EXTENSION=native
HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE=spanish
```

**Choosing for a CJK or mixed-language bank**:
```bash
HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pgroonga
```

:::note External PostgreSQL required

The `pgroonga` and `pg_search` backends depend on PostgreSQL extensions that are **not** bundled with the embedded `pg0` database Hindsight uses when `HINDSIGHT_API_DATABASE_URL` is unset. On that default single-container setup neither backend can be enabled — point `HINDSIGHT_API_DATABASE_URL` at an external PostgreSQL that has the extension installed, as the `docker/docker-compose/pgroonga/` and `docker/docker-compose/pg_search/` recipes do.

:::

The `native` and `pgroonga` knobs do not apply to each other — `pgroonga`'s tokenizer is set at index creation and ignores `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`.

#### Forcing the LLM Output Language

Independent from the BM25 backend, `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` forces every LLM-generated artifact into a single language regardless of the source content. This applies uniformly to:

- **Retain** — fact text, context, and entity names extracted from source documents.
- **Consolidation** — observations / mental models synthesized from those facts.
- **Reflect** — the final natural-language response returned by the reflect API.

```bash
# Every LLM call (retain, consolidation, reflect) emits Spanish regardless of source language.
HINDSIGHT_API_LLM_OUTPUT_LANGUAGE=Spanish
```

Common patterns:
- **Aligned, single-language bank**: `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE=spanish` + `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE=Spanish` — store, index, and respond in Spanish even when sources are mixed.
- **Mixed-language bank with multilingual indexing**: `HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pgroonga` + leave `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` unset — preserve source-language facts; pgroonga handles all of them in one index; reflect responds in the query's language.
- **Cross-lingual unification**: `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE=English` — every fact, observation, and reflect response in English regardless of source. Useful when the consumer (an English-only LLM, dashboard, or downstream pipeline) needs uniform output.

Leave `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` unset to preserve the source/query language across the pipeline (the default).

#### Default behaviour when no output language is set

With `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` unset, retain and consolidation are both instructed to keep their output in the language of the source material. For observations specifically:

- **Language is decided per observation**, from the facts that observation is built on — not from the batch. A batch mixing Chinese and English facts produces Chinese observations for the Chinese facts and English observations for the English ones. When a single observation merges facts written in several languages, the majority language of those facts wins.
- **Updates follow the new facts.** When an existing observation is written in a different language from the facts updating it, the whole observation is rewritten in the new facts' language. A bank whose observations previously drifted into the wrong language converges back as new facts arrive.
- **Names and technical terms are never translated** — proper nouns, product and place names, identifiers, code, and units stay as written in the source facts, whatever the surrounding language.

This is prompt-level guidance, not a hard guarantee: a model that ignores instructions can still emit the wrong language. Set `HINDSIGHT_API_LLM_OUTPUT_LANGUAGE` explicitly when a bank must be single-language no matter what its sources look like.

---

## Best Practices

### 1. Use Multilingual Models for Non-English Content
If you primarily work with non-English content, configure multilingual embedding and reranker models. English-only models will still store your content correctly, but semantic search quality will be degraded.

### 2. Keep Content in One Language Per Retain Call
While mixed content works, keeping each `retain` call in a single language produces more consistent results.

### 3. Query in the Same Language as Your Content
For best results, query using the same language as your stored content. Cross-language queries (e.g., English query for Chinese content) may work but results can vary depending on your embedding model.

---

## Technical Details

Multilingual support is implemented through LLM prompt instructions rather than external language detection libraries. This approach:

- **Requires no additional dependencies**
- **Works with any LLM** that supports multiple languages
- **Handles edge cases** like mixed-language content naturally
- **Preserves semantic meaning** better than rule-based translation

The LLM is instructed to:
1. Detect the input language
2. Extract all facts, entities, and descriptions in that same language
3. Never translate to English unless the input is in English

---

### Hindsight Docs Docs Developer Observations (`hindsight-docs/docs/developer/observations.mdx`)

---
sidebar_position: 5
---

import CodeSnippet from '@site/src/components/CodeSnippet';
import recallPy from '!!raw-loader!@site/examples/api/recall.py';
import memoryBanksPy from '!!raw-loader!@site/examples/api/memory-banks.py';
import observationsPy from '!!raw-loader!@site/examples/api/observations.py';
import Figure from '@site/src/components/Figure';
import observationsFigure from '@site/figures/observations.json';

# Observations: Knowledge Consolidation

After memories are retained, Hindsight automatically consolidates related facts into **observations** — deduplicated, evidence-grounded beliefs the bank has built up from multiple memories. Each observation tracks its supporting evidence (with exact quotes) and a proof count, and is refined rather than overwritten when new evidence arrives.

<Figure doc={observationsFigure} />

---

## What Are Observations?

Observations are **consolidated knowledge** built from multiple facts. Unlike raw facts — which are individual pieces of information — observations represent deduplicated beliefs, preferences, and learnings grounded in accumulated evidence. They are not summaries the LLM invents on the fly: each observation is backed by specific source memories, carries a proof count, and evolves as new evidence supports, contradicts, or extends it.

| Raw Facts | Observation |
|-----------|--------------|
| "Alice prefers Python" | "Alice is a Python-focused developer who values readability and simplicity" |
| "Alice dislikes verbose code" | |
| "Alice recommends type hints" | |

Observations provide:
- **Deduplication**: One durable belief instead of many overlapping facts
- **Grounding**: Every observation references the specific memories (with quotes) that support it
- **Evolution**: Refined as evidence strengthens, weakens, or contradicts it — history is preserved
- **Freshness awareness**: when newer memories haven't been consolidated yet, `reflect` treats the affected observations as stale and verifies them against raw facts
- **Efficiency**: Condensed knowledge for faster retrieval

---

## How Consolidation Works

### Automatic Background Processing

After `retain()` completes, the consolidation engine runs automatically:

1. **New facts analyzed** — Each new fact is compared against existing observations
2. **Pattern detection** — Related facts are grouped and synthesized
3. **Observation creation/update** — New observations are created or existing ones refined
4. **Evidence tracking** — Each observation maintains references to supporting facts

### Near-Duplicate Reconciliation

Consolidation can still produce two observations that say the same thing in slightly different words — for example when a weaker model writes a near-identical observation instead of refining the existing one, or when refining an observation reshapes its wording so it overlaps another one. Left alone, these near-duplicates clutter recall with redundant beliefs.

When enabled, Hindsight reconciles them automatically. Whenever an observation is created **or** updated, it is compared against the existing observations it most closely resembles. If one is highly similar, a focused check decides whether to **merge** them into a single belief (folding both sets of supporting evidence together) or **keep** them separate. Because the check reads the full text of both, observations that differ in a meaningful detail — a number, a negation, a named entity or language — are correctly kept apart rather than collapsed.

This is controlled by the [`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`](/developer/configuration#observations) setting: the cosine similarity at or above which two observations are reconciled. It is **enabled by default** (`0.97`); a lower value reconciles more aggressively, and `1.0` disables it. Reconciliation runs on PostgreSQL deployments only — it is skipped on Oracle regardless of the threshold.

Reconciliation only compares observations **within the same tag scope**. If you tag retains with a unique per-call value (e.g. a `session-id`), each session lands in its own scope and never dedups against the others — producing one near-identical observation per session. To consolidate across those volatile tags, retain with [`observation_scopes: "shared"`](/developer/api/retain#shared), which scopes observations to one global, untagged belief while leaving the session tag on the source facts for recall filtering.

### Disabling Auto-Consolidation

Set `HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION=false` (or configure per-bank via the [bank config API](/developer/api/memory-banks#observations-configuration)) to prevent consolidation from running automatically after retain, delete, and update operations. When disabled, consolidation only runs when you explicitly call the [consolidate endpoint](#trigger-consolidation).

This is useful when you want full control over consolidation timing — for example, batching many retains before consolidating, or running consolidation only for specific scopes.

### Targeted Consolidation

By default, consolidation processes **all** unconsolidated memories in a bank. You can scope it to specific tag sets using the `observation_scopes` parameter on the consolidate endpoint:

<CodeSnippet code={observationsPy} section="targeted-consolidation" language="python" />

Each scope is a list of tags. A memory matches a scope if its tags **contain all** tags in that scope. For example, scope `["user:alice"]` matches memories tagged `["user:alice", "team:eng"]`.

When `observation_scopes` is omitted, all unconsolidated memories are processed (backward compatible).

### Evidence-Based Evolution

Observations evolve as new evidence arrives:

| Event | What the bank learns | Observation state |
|-------|---------------------|----------------|
| **Day 1** | "Redis is open source under BSD license" | "Redis is excellent for caching — fast, reliable, and OSS-friendly" (2 supporting facts) |
| **Day 2** | "Redis has great community support" | Observation reinforced (3 supporting facts) |
| **Day 30** | "Redis changed license to SSPL" | Observation refined: "Redis is technically strong, but has license concerns for cloud" |
| **Day 45** | "Valkey forked Redis under BSD" | New observation: "Consider Valkey for new projects requiring true OSS" |

### Handling Contradictory Evidence

What happens when a new fact contradicts an existing observation?

The consolidation engine doesn't blindly overwrite — it **reconciles** the contradiction by capturing the evolution:

**Example: User preference changes**

| Time | Fact | Observation |
|------|------|--------------|
| Week 1 | "User says they love React" | "User prefers React for frontend development" |
| Week 2 | "User praises React's component model" | "User is enthusiastic about React, particularly its component model" |
| Week 3 | "User says they've switched to Vue and won't use React anymore" | "User was previously a React enthusiast who appreciated its component model, but has now switched to Vue and no longer uses React" |

Notice how the final observation captures the **full journey** — not just "User prefers Vue" but the complete evolution of their preference. This nuanced understanding means:

- Your agent won't recommend React tutorials to someone who explicitly moved away from it
- Your agent understands *why* this matters (they were enthusiastic before, so this is a deliberate choice)
- Your agent can reference this history when relevant ("I know you used to work with React...")

The system:
1. **Detects the conflict** — New fact contradicts existing observation
2. **Preserves history** — Incorporates the previous understanding into the new observation
3. **Creates nuanced observation** — Synthesizes a richer understanding that captures the change
4. **Updates freshness** — Marks the observation as recently updated

**Example: Correcting misinformation**

| Time | Fact | Observation |
|------|------|--------------|
| Day 1 | "Alice works at Google" | "Alice is a Google employee" |
| Day 10 | "Alice actually works at Meta, not Google" | "Alice works at Meta (previously thought to work at Google)" |

When a fact explicitly corrects previous information, the observation is updated to reflect the correction while noting the previous understanding. The raw facts are always preserved, so you can trace back to see what was originally stated and when it was corrected.

---

## Observations in Retrieval

Observations are automatically included in both `recall()` and `reflect()` operations:

### In Recall

Observations are returned alongside raw facts, filtered by the `types` parameter:

<CodeSnippet code={recallPy} section="recall-with-observations" language="python" />

### In Reflect

The reflect agent uses **hierarchical retrieval**:

1. **[Mental Models](/developer/api/mental-models)** — User-curated summaries (highest priority)
2. **Observations** — Consolidated knowledge with freshness awareness
3. **Raw Facts** — Ground truth for verification

The agent automatically queries observations and uses them to inform its reasoning.

---

## Freshness Awareness

Observations track when they were last updated. During reflect, the agent considers freshness:

- **Fresh observations**: Used directly for reasoning
- **Stale observations**: Agent verifies against current facts before relying on them

This ensures responses stay accurate even as the underlying data changes.

---

## Observation Scopes

By default, observations are scoped to all of a memory's tags combined. The `observation_scopes` retain parameter lets you control this — building separate observations per tag, per combination, or with a custom list of scopes. This is key when a single memory carries multiple tags and you want each tag to accumulate its own observations independently.

See [`observation_scopes` in the Retain API](./api/retain#observation_scopes) for the full explanation and options.

To inspect the scopes that already exist in a bank, call `GET /v1/default/banks/{bank_id}/observations/scopes`. The response lists each exact tag set with its observation count; the empty tag list is the global scope. The listing is paged (`limit`, default 100, and `offset`), with `total` reporting how many distinct scopes the bank holds. Use a returned scope as `tags` with `tags_match: "exact"` when you need to filter to that precise observation scope without also matching observations that carry extra tags. To recall **only** the global scope — the untagged observations written by `observation_scopes: "shared"` — pass an empty list with exact matching: `tags: []`, `tags_match: "exact"`.

### Changing the Scopes of Existing Documents {#changing-scopes}

The scoping is stored on each memory when it is retained. To move documents that are already in the bank, for example a bank retained with per-session tags under the default `combined` scoping that should now consolidate across sessions, send the new value on the document:

<CodeSnippet code={observationsPy} section="change-document-scopes" language="python" />

It takes the same values as retain (`"combined"` returns to the default). Nothing is re-extracted, and the memories keep their tags. A memory is consolidated again only if the change moves the scopes it resolves to. In that case its observations are deleted and rebuilt in the new scope by the next consolidation, and so are observations that other documents' memories shared with it. A change that resolves to the same scopes deletes nothing and queues nothing. Examples: `combined` over a single tag versus `[["that-tag"]]`, or re-sending the current value. The new value is also saved on the document, so a later reprocess keeps it.

To move a whole bank, list its documents (filtering by tag if needed) and send one PATCH per document. Two things to know first:

- **Update the client first.** A retain that appends to a document sends its own `observation_scopes`. If your client still retains without the new value, the next append moves that document back.
- **Expect a rebuild.** Every moved memory loses its observations until consolidation runs again. On a large bank that is one LLM pass over most of its facts, and recall returns fewer observations meanwhile. Mental models citing the deleted observations refresh as usual. Moving documents in batches spreads the cost.

---

## Observations Mission

You can define exactly what this bank should synthesise by setting an **observations mission** (`observations_mission`). This replaces the built-in durable-knowledge rules with your own instructions, letting you control what shape observations take.

```
e.g. Observations are stable facts about people and projects.
     Always include preferences, skills, and recurring patterns.
     Ignore one-off events and ephemeral state.
```

Leave it blank to use the server default — durable, specific facts that stay true over time (preferences, skills, relationships, recurring patterns), with ephemeral state filtered out.

**Examples:**

| `observations_mission` | What gets synthesised |
|------------------------|----------------------|
| *(unset — default)* | Durable facts: preferences, skills, relationships, recurring patterns |
| *"Observations are weekly summaries of sprint outcomes and blockers"* | Broad event summaries grouped by time period |
| *"Observations are stable facts about named individuals only"* | Person-centric knowledge, tied to specific people |
| *"Observations are recurring patterns in customer support interactions"* | Failure modes, common requests, pain points |

Set `observations_mission` via the [bank config API](/developer/api/memory-banks#observations-configuration) or the [`HINDSIGHT_API_OBSERVATIONS_MISSION`](/developer/configuration#observations) environment variable.

---

## Consolidation Strategies

The mission, the observation cap and the source-facts token limits apply to every scope in the bank. **Consolidation strategies** (`consolidation_strategies`) let specific scopes use their own instead.

This is what makes one bank work across several audiences. Say each memory is tagged with its author, their team and the company, and retained with `observation_scopes: [["user:dana"], ["team:exec"], ["company:acme"]]`. Each scope builds its own observations. But with a single mission, the company-wide scope gets the same detail as Dana's own — names, deal sizes, anything said in confidence — just visible to more people. A strategy fixes that:

```json
[
  {
    "scopes": [{"tags": ["company:*"]}],
    "observations_mission": "This scope is shared with the whole company. Record only general, industry-level trends. Never name a specific company, person, deal size or funding stage.",
    "max_observations_per_scope": 20
  },
  {
    "scopes": [{"tags": ["team:*"]}],
    "observations_mission": "Record decisions the team must act on."
  }
]
```

With that in place, Dana's scope keeps "Acme Robotics signed a 3-year lease for a 4,000 GPU cluster", while the company scope gets "Companies are leasing GPU clusters to run open-weight models".

How a strategy is matched:

- **`scopes`** is a list of alternatives. Each is `{"tags": [...], "tags_match": "all" | "exact"}`, where `tags` are [fnmatch](https://docs.python.org/3/library/fnmatch.html) patterns (`*` matches any text). A strategy applies to a consolidation scope when **any** of its alternatives matches it (OR); the tags inside one alternative must all be present (AND).
- **`tags_match`**, set per alternative, decides whether other tags are allowed:

  | `tags_match` | `{"tags": ["company:*", "team:*"]}` matches… | …but not |
  |---|---|---|
  | `"all"` *(default)* | `{company:acme, team:exec}`, `{user:dana, team:exec, company:acme}` | `{company:acme}` (no team) |
  | `"exact"` | `{company:acme, team:exec}` only | `{user:dana, team:exec, company:acme}` (extra `user:` tag) |

  Because the mode is per alternative, one strategy can mix them — `[{"tags": ["company:*"], "tags_match": "exact"}, {"tags": ["team:*"]}]` claims scopes that are *only* a company, or that have a team among other tags. To match *any one* of several tags, give each its own alternative: `[{"tags": ["company:*"]}, {"tags": ["team:*"]}]`. Excluding a tag ("has a company but no user") is not expressible.

  :::caution The untagged scope can never be claimed
  No pattern matches a scope with no tags, including a bare `["*"]`. That means a bank using
  [`observation_scopes: "shared"`](./api/retain#observation_scopes), which consolidates over the single untagged
  scope, has one scope that no strategy can reach: it always uses the bank-wide values. If you need a
  differently-briefed catch-all, give it a real tag and target that instead of relying on `shared`.
  :::

  Remember that a strategy matches **observation scopes** — the tag sets consolidation groups observations under, set by [`observation_scopes`](./api/retain#observation_scopes) at retain time — not the tags of individual memories. A memory tagged with a user, a team and a company but retained with `observation_scopes: [["user:dana"], ["team:exec"], ["company:acme"]]` produces three single-tag scopes, none of which has both a company and a team.
- **What a strategy can set:** `observations_mission`, `max_observations_per_scope`, `consolidation_source_facts_max_tokens` and `consolidation_source_facts_max_tokens_per_observation`. Every one is optional.
- **Anything a strategy leaves unset, and every scope no strategy matches,** uses the bank-wide value. The control plane shows these bank-wide values as the **Default** strategy.

### When two strategies match the same scope

**The first one in the list wins, whole.** Exactly one strategy applies to a scope. List order is priority order — left to right in the control plane's tabs.

The strategies are never mixed. If the winning strategy leaves a setting empty, that setting comes from **Default**, not from a later strategy that also matches. For example, with:

```json
[
  {"scopes": [{"tags": ["company:acme"]}], "max_observations_per_scope": 5},
  {"scopes": [{"tags": ["company:*"]}], "observations_mission": "Record only general trends."}
]
```

the scope `company:acme` gets a cap of 5 and the **bank-wide mission**, not "Record only general trends". The second strategy still applies to every other `company:*` scope. To give `company:acme` both, put both settings on the first strategy.

To see which strategy a scope uses, open the strategy in the control plane: each rule shows how many existing scopes it matches and how many an earlier strategy takes, with examples. The same answer is available from `POST /v1/default/banks/{bank_id}/consolidation-strategies/preview`, which takes a draft `strategies` list (nothing is saved) and reports, per strategy and rule, the matching scopes and which strategy actually handles each — computed with consolidation's own matching. It scans up to 10,000 distinct scopes; beyond that `complete` is `false` and the counts are lower bounds. Only scopes that already have observations exist to be previewed.

A strategy that sets nothing is ignored: it matches no scope and doesn't block later ones.

A malformed strategy is rejected when you save it through the control plane or the bank config API — an unknown key
(`"scope"` for `"scopes"`), a tag list that is not a list of strings, an unknown `tags_match` — with a 400 naming the
offending entry. The `HINDSIGHT_API_CONSOLIDATION_STRATEGIES` environment variable is **not** validated this way: a
malformed entry is dropped silently, and an unrecognised `tags_match` falls back to `"all"` rather than erroring. If you
configure strategies by environment variable, check the preview afterwards to confirm each one claims what you expect. An *incomplete* one is accepted, because the control plane saves strategies as you type them: a rule with no tags yet, or a strategy with no setting yet, is stored and simply ignored until you finish it. Strategies survive [bank template](/developer/api/memory-banks) export and import unchanged.

Each scope is consolidated in its own LLM call, so one scope's mission never reaches another scope's call.

Set `consolidation_strategies` in the control plane (bank **Configuration → Observations**), via the [bank config API](/developer/api/memory-banks#observations-configuration), or with the [`HINDSIGHT_API_CONSOLIDATION_STRATEGIES`](/developer/configuration#observations) environment variable. It replaces the older `observation_scope_limits`, which still works but is checked only after the strategies, and only
ever carried the observation cap.

:::note Porting a rule from `observation_scope_limits`
The two fields match differently. `observation_scope_limits` is exact-cover only: its pattern must account for every tag
in the scope. A strategy defaults to `tags_match: "all"`, which allows extra tags. Copying a pattern across unchanged
therefore widens what it matches. Add `"tags_match": "exact"` to keep the old behaviour.

Note also that a strategy claiming a scope without setting `max_observations_per_scope` leaves the cap to
`observation_scope_limits`, falling back past any later strategy that would have set one.
:::

---

## Observation Lifecycle & Invalidation

### When Memories Are Deleted

Observations are derived from source memories. When source memories are removed, Hindsight automatically keeps observations consistent:

| Action | Effect on observations |
|--------|----------------------|
| Delete a document | All observations derived from the document's memories are deleted |
| Delete individual memories (by type) | Observations sourced from those memories are deleted |
| Delete an entire bank | All observations are deleted along with everything else |

After deletion, the **remaining source memories** that fed the affected observations have their consolidation state reset, so they will be re-consolidated on the next consolidation run and produce fresh observations.

### Clearing Observations for a Specific Memory

You can clear all observations derived from a single memory without deleting the memory itself. This is useful when you want to force re-synthesis of a memory's contribution to consolidated knowledge.

Use the `DELETE /v1/default/banks/{bank_id}/memories/{memory_id}/observations` endpoint. This will:
1. Delete all observations that list the memory as a source
2. Reset `consolidated_at` on the memory itself and any other source memories that contributed to those observations
3. Trigger a consolidation job so fresh observations are produced automatically

### Resetting All Observations

To wipe all consolidated knowledge and start over:

<CodeSnippet code={observationsPy} section="clear-observations" language="python" />

This resets the consolidation state for all source memories in the bank, so the next consolidation run will re-derive all observations from scratch.

---

## Trigger Consolidation {#trigger-consolidation}

Use the consolidate endpoint to manually trigger consolidation:

```http
POST /v1/default/banks/{bank_id}/consolidate
Content-Type: application/json

{
  "observation_scopes": [["user:alice"], ["team:engineering"]]
}
```

The request body is optional. When omitted (or sent as an empty body), all unconsolidated memories in the bank are processed.

| Parameter | Type | Description |
|-----------|------|-------------|
| `observation_scopes` | `list[list[str]]` \| `null` | Optional list of tag scopes. Only memories whose tags contain all tags in at least one scope are processed. Omit for a full-bank sweep. |

## Configuration

Observation consolidation runs automatically by default. You can disable auto-consolidation with [`HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION`](/developer/configuration#observations) and trigger it on-demand via the [consolidate endpoint](#trigger-consolidation). Monitor consolidation progress via the [Operations API](./api/operations).

---

## Next Steps

- [**Retain**](./retain) — How facts are stored and trigger consolidation
- [**Recall**](./retrieval) — How observations are retrieved
- [**Reflect**](./reflect) — How the agentic loop uses observations
- [**Mental Models**](./api/mental-models) — User-curated summaries for common queries

---

### Hindsight Docs Docs Developer Oracle (`hindsight-docs/docs/developer/oracle.md`)

# Oracle Database

Hindsight uses PostgreSQL as its default storage backend, but it also runs on
**Oracle Database 23ai** for organizations that standardize on Oracle
infrastructure. All memory operations — retain, recall, and reflect — work the
same way on Oracle; the backend is selected with a single environment variable.

This guide covers everything needed to run Hindsight against Oracle: the
prerequisites, the driver, a local quick start, provisioning a production
database, running migrations, and the handful of behavioural differences from
PostgreSQL.

:::info When to use Oracle
Oracle is the right choice when your organization already runs Oracle and needs
Hindsight to live inside that footprint. For everything else, the default
PostgreSQL backend is simpler to operate — see [Storage](./storage) for the
rationale. Oracle and PostgreSQL are configured independently; you pick one per
deployment.
:::

## Requirements

| Requirement | Details |
|-------------|---------|
| Oracle Database | **23ai** (23.4+). [Oracle Database Free 23ai](https://www.oracle.com/database/free/) works for development. |
| `VECTOR` type | Used for embeddings. Requires the schema to live in an **ASSM tablespace** (see below). |
| Oracle Text | Full-text search uses Oracle Text indexes. The schema user needs the `CTXAPP` role. |
| Driver | [`python-oracledb`](https://python-oracledb.readthedocs.io/) ≥ 2.5.0, running in **thin mode** — pure Python, no Oracle Instant Client required. |

:::warning The schema must use an ASSM tablespace
Oracle's `SYSTEM` tablespace uses *manual* segment space management (MSSM),
which **does not support `VECTOR` columns**. Create the Hindsight user in a
tablespace with **Automatic Segment Space Management (ASSM)** — otherwise
migrations fail when they create embedding columns. The provisioning SQL below
does this for you.
:::

## Install the driver

The Oracle driver is an optional extra — it is not bundled with the default
packages. Install it alongside Hindsight:

```bash
# With the packaged extra
pip install "hindsight-api-slim[oracle]"

# Or add the driver to an existing install (e.g. the full hindsight-api package)
pip install hindsight-api oracledb
```

If the driver is missing at startup, Hindsight fails with:
`python-oracledb is required for Oracle backend. Install it with: pip install oracledb`.

## Quick start (local Oracle)

The fastest way to try Hindsight on Oracle is the bundled helper script, which
starts a local **Oracle Database Free 23ai** container, provisions the test
user with the correct tablespace and grants, and prints a ready-to-use
connection URL:

```bash
# Start Oracle Free in Docker and bootstrap the hindsight_test user
./scripts/dev/start-oracle.sh

# ...prints:
#   export HINDSIGHT_API_DATABASE_BACKEND=oracle
#   export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight_test:hindsight_test@localhost:1521/FREEPDB1'

# Stop and remove the container when done
./scripts/dev/stop-oracle.sh
```

A cold start takes 60–120s while the database initializes. If the first run
reports a provisioning error, the database was still starting up — just re-run
`./scripts/dev/start-oracle.sh` once the container is healthy (it is idempotent).

Once the script prints the connection URL, export the variables it shows, and
**also set the schema** to the Oracle user it created:

```bash
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT_TEST
```

Then run migrations and start the API (see the steps below). Setting the schema
is required on Oracle — see [step 3](#3-configure-hindsight). This is the same
setup Hindsight's CI uses to test the Oracle backend.

## Production setup

### 1. Provision the schema user

Connect to your pluggable database as a privileged user (for example `SYSTEM`)
and create a dedicated tablespace and user for Hindsight. The tablespace **must**
use ASSM so `VECTOR` columns are supported:

```sql
-- ASSM tablespace (required for VECTOR columns). Size to your data volume.
CREATE BIGFILE TABLESPACE hindsight_ts
    DATAFILE 'hindsight_ts.dbf' SIZE 2G AUTOEXTEND ON NEXT 500M MAXSIZE UNLIMITED
    EXTENT MANAGEMENT LOCAL
    SEGMENT SPACE MANAGEMENT AUTO;

-- Dedicated schema user
CREATE USER hindsight IDENTIFIED BY "<strong-password>"
    DEFAULT TABLESPACE hindsight_ts
    TEMPORARY TABLESPACE temp
    QUOTA UNLIMITED ON hindsight_ts;

-- Object privileges Hindsight's migrations need
GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, CREATE PROCEDURE TO hindsight;

-- Oracle Text (full-text search indexes)
GRANT CTXAPP TO hindsight;
```

:::note Least privilege
`CONNECT` and `RESOURCE` cover the basics; the explicit `CREATE TABLE / SEQUENCE
/ VIEW / PROCEDURE` grants and `CTXAPP` are what the schema migrations require.
No `DBA` role is needed. On a managed service where `CREATE TABLESPACE` is not
available directly, provision the schema through the platform's admin tooling —
the requirements are unchanged: an **ASSM** default tablespace (needed for
`VECTOR` columns) plus the `CTXAPP` role.
:::

### 2. Build the connection URL

Hindsight uses SQLAlchemy-style URLs. The Oracle form is:

```
oracle+oracledb://USER:PASSWORD@HOST:PORT/SERVICE_NAME
```

| Part | Example | Notes |
|------|---------|-------|
| `USER` / `PASSWORD` | `hindsight` / `s3cret` | The schema user from step 1. URL-encode reserved characters (`@`, `/`, `:`) in the password. |
| `HOST:PORT` | `db.internal:1521` | The listener host and port (Oracle default is `1521`). |
| `SERVICE_NAME` | `FREEPDB1` | The **service name** of your pluggable database (not the SID). `FREEPDB1` for Oracle Free. |

Example:

```
oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1
```

#### Full connect descriptors and TNS aliases

The form above is Easy Connect, which can only express `host:port/service`. When you
need more — Autonomous Database over TCPS, `retry_count`, `ssl_server_dn_match`, or a
TNS alias from a `tnsnames.ora` — leave the host empty and pass the descriptor as a
`dsn` query parameter:

```
oracle+oracledb://USER:PASSWORD@/?dsn=DESCRIPTOR_OR_TNS_ALIAS
```

The descriptor has to be percent-encoded, like the password. For example, the
Autonomous Database descriptor

```
(description=(retry_count=20)(address=(protocol=tcps)(port=1522)(host=adb.example.com))(connect_data=(service_name=abc_low))(security=(ssl_server_dn_match=yes)))
```

becomes

```bash
export HINDSIGHT_API_DATABASE_URL="oracle+oracledb://hindsight:s3cret@/?dsn=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.stdin.read().strip()))' <<< '(description=(retry_count=20)(address=(protocol=tcps)(port=1522)(host=adb.example.com))(connect_data=(service_name=abc_low))(security=(ssl_server_dn_match=yes)))')"
```

:::warning Wallet-based mTLS is still not supported
Hindsight passes no wallet directory or wallet password to the driver, so
connections that require a wallet (mTLS) do not work. TCPS with
`ssl_server_dn_match` does. Otherwise secure the connection at the network layer
(private networking, VPN, or a TLS-terminating proxy).
:::

### 3. Configure Hindsight

Point Hindsight at Oracle with two environment variables:

```bash
export HINDSIGHT_API_DATABASE_BACKEND=oracle
export HINDSIGHT_API_DATABASE_URL='oracle+oracledb://hindsight:s3cret@db.internal:1521/ORCLPDB1'
export HINDSIGHT_API_DATABASE_SCHEMA=HINDSIGHT   # the Oracle user from step 1
```

`HINDSIGHT_API_DATABASE_BACKEND` defaults to `postgresql`; set it to `oracle` to
select the Oracle backend.

:::warning Set `DATABASE_SCHEMA` to your Oracle user
`HINDSIGHT_API_DATABASE_SCHEMA` defaults to `public` — a PostgreSQL concept. On
Oracle a schema **is a user**, there is no `public` schema, and leaving the
default makes migrations fail with `ORA-01435: user does not exist`. Set it to
the schema user you created in step 1, spelled exactly as Oracle stores it —
**uppercase** (e.g. `HINDSIGHT`) unless you created the user with a quoted
lower-case name.
:::

See [Configuration → Database](./configuration#database) for the full list of
database variables.

### 4. Run migrations

Hindsight runs the same schema migrations on Oracle as on PostgreSQL. By default
the API applies them automatically on startup
(`HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true`). To run them explicitly — for
example in a controlled deploy step — use:

```bash
hindsight-admin run-db-migration
```

This routes through the dialect-aware migration runner and creates the Oracle
schema. (Unlike the admin CLI's data-movement commands, `run-db-migration`
is fully supported on Oracle — see [Limitations](#limitations-vs-postgresql).)

:::warning Migrate with your runtime embedding dimension
The embedding `VECTOR` columns are sized to the dimension of the configured
embeddings model. Run migrations with the **same embeddings provider/model you
will serve with** — otherwise the column dimension won't match the vectors the
API produces and retain fails with `ORA-51803: Vector dimension count must
match…` (for example, a schema built for a 384-dim local model rejects the
1536-dim vectors from OpenAI `text-embedding-3-small`). If you change the
embeddings model later, re-run migrations with `--embedding-dimension <N>` to
resize the columns.
:::

### 5. Start the API

```bash
hindsight-api
```

On startup Hindsight logs the resolved database (with credentials masked); it
should show your Oracle host and confirm the Oracle backend is active.

## Configuration reference

Oracle-relevant settings, all documented in full on the
[Configuration](./configuration) page:

| Variable | Purpose |
|----------|---------|
| `HINDSIGHT_API_DATABASE_BACKEND` | `postgresql` (default) or `oracle`. |
| `HINDSIGHT_API_DATABASE_URL` | `oracle+oracledb://…` connection URL. |
| `HINDSIGHT_API_DATABASE_SCHEMA` | Schema/user for the tables. On Oracle set this to your schema user (uppercase); the `public` default fails. |
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Auto-apply migrations when the API boots (default `true`). |

## Limitations vs PostgreSQL

Memory operations behave identically on Oracle, but a few operational and
internal details differ:

- **Admin CLI data commands are PostgreSQL-only.** `hindsight-admin` backup,
  restore, bank export/import, and worker-status use asyncpg binary `COPY` and
  `TRUNCATE`, which are PostgreSQL-specific and not available on Oracle.
  Schema migrations (`run-db-migration`) *are* supported on Oracle.
- **No embedded database.** The `pg0` embedded PostgreSQL used for zero-config
  local development has no Oracle equivalent — Oracle always requires a running
  instance (use the [quick-start script](#quick-start-local-oracle) locally).
- **Consolidation reconciliation is skipped.** The similarity-based
  near-duplicate reconciliation pass in consolidation
  (`HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD`) is a PostgreSQL-only path;
  consolidation still runs on Oracle, without that extra reconciliation step.
- **Entity resolution uses Oracle fuzzy matching.** Fuzzy entity lookup during
  retain uses Oracle's text matching rather than PostgreSQL's `pg_trgm` trigram
  matching. Behaviour is equivalent; the underlying mechanism differs.

## Troubleshooting

| Symptom | Cause / Fix |
|---------|-------------|
| `python-oracledb is required for Oracle backend` | The driver isn't installed. Run `pip install oracledb` (or install the `[oracle]` extra). |
| `ORA-01435: user does not exist` on migration | `HINDSIGHT_API_DATABASE_SCHEMA` is unset (defaults to `public`) or misspelled. Set it to your Oracle schema user, uppercase (e.g. `HINDSIGHT`). |
| `ORA-51803: Vector dimension count must match` on retain | The schema was migrated with a different embedding dimension than the running embeddings model. Migrate with the same embeddings config, or re-run `run-db-migration --embedding-dimension <N>`. |
| Migration errors when creating embedding/`VECTOR` columns | The schema user's default tablespace is not ASSM (often the `SYSTEM` tablespace). Recreate the user in an ASSM tablespace as shown above. |
| Full-text search errors / missing Oracle Text index | The schema user is missing the `CTXAPP` role. Run `GRANT CTXAPP TO <user>;`. |
| `ORA-12514` / service not found | The URL uses a SID or wrong service name. Use the pluggable database **service name** (e.g. `FREEPDB1`), not the SID. |
| Login works manually but fails from Hindsight | A reserved character in the password isn't URL-encoded. Encode `@ / : ?` in the `DATABASE_URL`. |

## See also

- [Storage](./storage) — why PostgreSQL is the default, and how Oracle fits in
- [Configuration](./configuration#database) — all database environment variables
- [Installation](./installation) — packaging and deployment options
- [Admin CLI](./admin-cli) — administrative commands (PostgreSQL-only data operations)

---

### Hindsight Docs Docs Developer Performance (`hindsight-docs/docs/developer/performance.md`)

# Performance

Hindsight is designed for high-performance semantic memory operations at scale. This page covers performance characteristics, optimization strategies, and best practices.

## Overview

Hindsight's performance is optimized across three key operations:

- **Retain (Ingestion)**: Batch processing with async operations for large-scale memory storage
- **Recall (Search)**: Sub-second semantic search with configurable thinking budgets
- **Reflect (Reasoning)**: Disposition-aware answer generation with controllable compute

## Design Philosophy: Optimized for Fast Reads

Hindsight is **architected from the ground up to prioritize read performance over write performance**. This design decision reflects the typical usage pattern of memory systems: memories are written once but read many times.

The system makes deliberate trade-offs to ensure **sub-second recall operations**:

- **Pre-computed embeddings**: All memory embeddings are generated and indexed during retention
- **Optimized vector search**: HNSW indexes enable fast approximate nearest neighbor search
- **Fact extraction at write time**: Complex LLM-based fact extraction happens during retention, not retrieval
- **Structured memory graphs**: Relationships and temporal information are resolved upfront

This means **Recall (search) operations are blazingly fast** because all the heavy lifting has already been done.

### Performance Comparison

| Operation | Typical Latency | Primary Bottleneck | Optimization Strategy            |
|-----------|----------------|-------------------|----------------------------------|
| **Recall** | 100-600ms | Re-ranker (on CPU) | Use GPU for re-ranking, or reduce budget |
| **Reflect** | 800-3000ms | LLM generation | Use faster LLM                   |
| **Retain** | 500ms-2000ms per batch | **LLM fact extraction** | Use high-throughput LLM provider |

Hindsight is designed to ensure your **application's read path (recall/reflect) is always fast**, even if it means spending more time upfront during writes. This is the right trade-off for memory systems where:

- Memories are retained in background processes or during low-traffic periods
- Memories are queried frequently in user-facing, latency-sensitive contexts
- The ratio of reads to writes is high (typically 10:1 or higher)

---

## Retain Performance

**Retain (write) operations are inherently slower** because they involve LLM-based fact extraction, entity recognition, temporal reasoning, relationship mapping, and embedding generation. **The LLM is the primary bottleneck for write latency.**

### Hindsight Doesn't Need a Smart Model

The fact extraction process is structured and well-defined, so smaller, faster models work extremely well. Our recommended model is `gpt-oss-20b` (available via Groq and other providers).

To maximize retention throughput:

1. **Use high-throughput LLM providers**: Choose providers with high requests-per-minute (RPM) limits and low latency
   - **Fast**: [Groq](https://groq.com) with `gpt-oss-20b` or other openai-oss models, self-hosted models on GPU clusters (vLLM, TGI)
   - **Slow**: Standard cloud LLM providers with rate limits

2. **Batch your operations**: Group related content into batch requests. Send as much data as you want in a single request — the only limit is the HTTP payload size.

3. **Use async mode for large datasets**: Queue operations in the background

4. **Parallel processing**: For very large datasets, use multiple concurrent retention requests with different `document_id` values

### Automatic Batch Optimization

**When using async retain, Hindsight automatically handles batch sizing for you.** You don't need to manually tune batch sizes or worry about optimal chunking.

How it works:
- **Send large batches**: Submit hundreds or thousands of items in a single async retain request
- **Automatic splitting**: Hindsight automatically splits large batches (>10,000 tokens) into optimized sub-batches
- **Parallel processing**: Sub-batches are processed concurrently in the background
- **Status tracking**: Parent operation aggregates status from all sub-batches
- **Token-based**: Batching counts real tokens, not characters

Benefits:
- Send entire documents or datasets in one API call
- Let Hindsight optimize the processing strategy
- Track overall progress via the parent operation status
- No need to manually split data into small batches

### Throughput

Factors affecting throughput:
- Document size and complexity
- LLM provider rate limits (for fact extraction)
- Database write performance
- Available CPU/memory resources

---

## Tuning for Local & Small Environments

Hindsight's defaults are tuned for cloud LLM providers and multi-core servers. When you run it on a laptop, a single GPU box, or against a **local LLM server** (llama.cpp, vLLM, LM Studio, Ollama) with a small fixed slot pool, those defaults can saturate the backend, time out, or thrash the CPU. This section collects the knobs that matter for low-resource setups.

### LLM concurrency

The default `HINDSIGHT_API_LLM_MAX_CONCURRENT=32` assumes a cloud provider that can absorb dozens of parallel requests. A local server with a handful of slots cannot — Hindsight will fill every slot and **starve any other client sharing the endpoint** (your main agent, another app, or a second Hindsight operation).

```bash
export HINDSIGHT_API_LLM_MAX_CONCURRENT=2
```

A value of `2` lets retain and consolidation run concurrently without blocking each other. If the endpoint is **shared** with other clients (other applications, agents, or workflows hitting the same llama-server / vLLM / LM Studio instance), reserve slots for them by lowering further — leave at least one slot free per shared client.

You can also split the budget per operation so background work never crowds out live reads. The per-operation caps compose *on top of* the global cap:

```bash
# global=4, with retain/consolidation capped low so reflect always has headroom
export HINDSIGHT_API_LLM_MAX_CONCURRENT=4
export HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT=1
export HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT=1
```

### Timeouts and retries

Small models on modest hardware generate tokens slowly, and the first request after startup pays a model-load cost. The default `HINDSIGHT_API_LLM_TIMEOUT=120` (seconds) can be too tight for a large local model on CPU — raise it to avoid spurious timeouts and wasted retries:

```bash
export HINDSIGHT_API_LLM_TIMEOUT=300        # allow slow local generation
export HINDSIGHT_API_LLM_MAX_RETRIES=2      # fail faster locally — retries rarely help a slow box
```

A local endpoint isn't rate-limited, so aggressive retry/backoff mostly adds latency on real failures. Lower retries and let genuine errors surface quickly.

### Smaller, faster models — and reasoning effort

Retain (fact extraction) is structured work that does not need a frontier model; reflect can use a lighter model still. On a constrained box, point each operation at the smallest model that holds up:

```bash
# Reflect on a small/fast model; retain on a slightly stronger structured-output model
export HINDSIGHT_API_REFLECT_LLM_MODEL=<small-fast-model>
export HINDSIGHT_API_RETAIN_LLM_MODEL=<structured-output-model>
```

If your model exposes a reasoning/thinking budget, set it low — extra reasoning tokens are pure latency for the extraction and consolidation paths. Nothing is sent unless you set it, so an unconfigured deployment runs at whatever effort the model defaults to (`none` is the value that switches thinking off entirely, where the backend supports it):

```bash
export HINDSIGHT_API_LLM_REASONING_EFFORT=low
```

Consolidation sends multiple facts to the LLM in a single call (default 8). On a small model with a limited context window, a large batch produces an oversized prompt and a long, error-prone response. Shrink the batch so each consolidation call stays small and reliable:

```bash
export HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE=2   # default 8; lower = smaller prompts, more calls
```

### Built-in llama.cpp tuning

The bundled `llamacpp` provider runs a llama.cpp server as a managed subprocess — no external server needed. Key knobs for small machines:

```bash
export HINDSIGHT_API_LLM_PROVIDER=llamacpp
export HINDSIGHT_API_LLM_MAX_CONCURRENT=2        # retain + consolidation without blocking
export HINDSIGHT_API_LLAMACPP_GPU_LAYERS=-1      # -1 = offload all layers to GPU; 0 = CPU only
export HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE=8192  # lower to save RAM/VRAM; raise for big batches
export HINDSIGHT_API_LLAMACPP_EXTRA_ARGS="--n_threads 8"  # match physical cores on CPU-only boxes
# export HINDSIGHT_API_LLAMACPP_NO_GRAMMAR=true  # faster, but less reliable JSON output
```

See [Built-in llama.cpp](./configuration#built-in-llamacpp) for the full option list.

### Reranker on CPU

Recall's bottleneck on a machine without a GPU is the cross-encoder reranker. The local reranker has several CPU/Apple-Silicon knobs that are quality-neutral but materially faster:

```bash
# Apple Silicon (MPS): half precision is 27–36% faster, quality-identical
export HINDSIGHT_API_RERANKER_LOCAL_FP16=true

# Sort pairs by length before batching — 36–54% faster, quality-identical by construction
export HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING=true

# Cap reranker parallelism so it doesn't thrash a small CPU under load (default 4)
export HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT=2

# On macOS, force CPU if MPS/XPC causes instability
# export HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=true
```

The biggest single win on CPU is reranking fewer candidates. By default Hindsight reranks up to 300 candidates per recall — shrink that pool to cut cross-encoder work proportionally:

```bash
export HINDSIGHT_API_RERANKER_MAX_CANDIDATES=100   # default 300; RRF pre-filters the rest
```

For a pure-CPU box that struggles with the cross-encoder, `flashrank` is a lighter ONNX-based reranker:

```bash
export HINDSIGHT_API_RERANKER_PROVIDER=flashrank
```

You can also reduce recall work directly: use a lower `budget` (`low`/`mid`) for everyday queries and reserve `high` for comprehensive reasoning. See [Recall Performance](#recall-performance) below.

---

## Recall Performance

### Budget

The `budget` parameter controls the search depth and quality. Choose based on query complexity — comprehensive questions that need thorough analysis benefit from higher budgets:

| Budget | Use Case |
|--------|----------|
| `low` | Quick lookups, real-time chat |
| `mid` | Standard queries, balanced performance |
| `high` | Comprehensive questions, thorough analysis |

### Optimization

1. **Appropriate budgets**: Use lower budgets for simple queries, higher for comprehensive reasoning
2. **Limit result tokens**: Set `max_tokens` to control response size (default: 4096)
3. **Include chunks**: Use `include_chunks` to retrieve the raw text that generated memories when you need additional context

### Database Performance

Hindsight uses PostgreSQL with pgvector for efficient vector search:

- **Index type**: HNSW for approximate nearest neighbor search
- **Typical query time**: 10-50ms for vector search on 100K+ facts
- **Scalability**: Tested with millions of facts per bank

## Reflect Performance

### Performance Characteristics

| Component | Latency        | Description |
|-----------|----------------|-------------|
| Memory search | 100-600ms      | Based on budget (low/mid/high) |
| LLM generation | 500-2000ms     | Depends on provider and response length |
| **Total** | **600-2600ms** | Typical end-to-end latency |

### Optimization Strategies

1. **Budget selection**: Use lower budgets when context is sufficient
2. **Context provision**: Provide relevant `context` to reduce recall requirements and steer towards more focused answers

## Best Practices

### Operations
- **Use appropriate budgets**: Don't over-provision for simple queries; use higher budgets for comprehensive reasoning
- **Batch retain operations**: Group related content together for better efficiency
- **Cache frequent queries**: Cache at the application level for repeated queries
- **Profile with trace**: Use the `trace` parameter to identify slow operations

### Scaling
- **Horizontal scaling**: Deploy multiple API instances behind a load balancer with shared PostgreSQL
- **Concurrency**: 100+ simultaneous requests supported; memory search scales with CPU cores
- **LLM rate limits**: Distribute load across multiple API keys/providers (typically 60-500 RPM per key)

### Cost Optimization
- **Use efficient models**: `gpt-oss-20b` via Groq for retain — Hindsight doesn't need frontier models
- **Enable provider Batch API**: Set `HINDSIGHT_API_RETAIN_BATCH_ENABLED=true` with async retain to cut LLM fact-extraction costs by 50% (supported on OpenAI and Groq; results delivered within 24 hours)
- **Control token budgets**: Limit `max_tokens` for recall, use lower budgets when possible
- **Optimize chunks**: Larger chunks (1000-2000 tokens) are more efficient than many small ones

### Monitoring
- **Prometheus metrics**: Available at `/metrics` — track latency percentiles, throughput, and error rates
- **Key metrics**: `hindsight_recall_duration_seconds`, `hindsight_reflect_duration_seconds`, `hindsight_retain_items_total`

---

### Hindsight Docs Docs Developer Rag Vs Hindsight (`hindsight-docs/docs/developer/rag-vs-hindsight.md`)

---
sidebar_position: 2
---

# RAG vs Memory

Traditional RAG (Retrieval-Augmented Generation) retrieves documents similar to a query. Hindsight provides structured memory with temporal reasoning, entity understanding, and belief formation.

## Capability Comparison

| Capability | RAG | Hindsight |
|------------|-----|-----------|
| **Search strategy** | Semantic similarity only | Semantic + keyword + graph + temporal |
| **Multi-hop reasoning** | Limited to retrieved chunks | Graph traversal across entity relationships |
| **Temporal queries** | Keyword matching ("spring") | Date parsing and range filtering |
| **Entity understanding** | None | Entity resolution, co-occurrence tracking |
| **Knowledge consolidation** | Stateless | Mental models that synthesize and evolve |
| **Disposition** | None | 3 traits (skepticism, literalism, empathy) influence interpretation |

## Architecture Comparison

### RAG

| Step | Operation |
|------|-----------|
| 1 | Embed query |
| 2 | Vector similarity search |
| 3 | Return top-k chunks |
| 4 | Generate response |

Single retrieval strategy. No state between queries.

### Hindsight

| Step | Operation |
|------|-----------|
| 1 | Parse query (extract temporal expressions, entities) |
| 2 | Execute 4 parallel retrievals: semantic, BM25, graph, temporal |
| 3 | Fuse results with RRF |
| 4 | Rerank with cross-encoder |
| 5 | Apply disposition traits |
| 6 | Generate response |

Multiple retrieval strategies. Persistent state across sessions.

## Example Scenarios

### Multi-Hop Reasoning

**Stored facts:**
- "Alice is the tech lead on Project Atlas"
- "Project Atlas uses Kubernetes"
- "Kubernetes cluster had an outage Tuesday"

**Query:** "Was Alice affected by recent issues?"

| System | Result |
|--------|--------|
| RAG | Retrieves facts about Alice only (no semantic similarity to "issues") |
| Hindsight | Traverses Alice → Project Atlas → Kubernetes → outage via entity links |

### Temporal Queries

**Stored facts with timestamps:**
- March: "Alice started microservices migration"
- April: "Alice completed auth service"
- October: "Alice focusing on performance"

**Query:** "What did Alice do last spring?"

| System | Result |
|--------|--------|
| RAG | Returns all Alice facts regardless of date |
| Hindsight | Parses "last spring" → March-May, filters to that range |

### Entity Understanding

**Stored facts about a user across sessions:**
- "Pro subscription"
- "Mobile app crashes in settings"
- "Switched to annual billing"
- "Desktop app working fine"

**Query:** "What do you know about my account?"

| System | Result |
|--------|--------|
| RAG | Lists disconnected facts |
| Hindsight | Returns connected facts via entity graph: subscription status, billing, known issues |

### Knowledge Evolution

**Week 1:** User struggles with async Python, succeeds with threads
**Week 3:** User asks about asyncio, implements async database calls

| System | Behavior |
|--------|----------|
| RAG | No memory of progression |
| Hindsight | Consolidates mental model "user prefers sync" → refines to "user growing comfortable with async" |

## When to Use Each

| Use Case | Recommended |
|----------|-------------|
| Document Q&A over static corpus | RAG |
| Search with no temporal requirements | RAG |
| AI assistants with persistent memory | Hindsight |
| Applications requiring entity tracking | Hindsight |
| Systems needing consistent disposition | Hindsight |
| Temporal queries ("last month", "in 2023") | Hindsight |

---

### Hindsight Docs Docs Developer Reflect (`hindsight-docs/docs/developer/reflect.mdx`)

---
sidebar_position: 4
---

import Figure from '@site/src/components/Figure';
import reflectLoop from '@site/figures/reflect.json';
import CodeSnippet from '@site/src/components/CodeSnippet';
import memoryBanksPy from '!!raw-loader!@site/examples/api/memory-banks.py';

# Reflect: Agentic Reasoning with Disposition

When you call `reflect()`, Hindsight runs an **agentic loop** that autonomously gathers evidence and reasons through the lens of the bank's disposition to generate contextual responses.

<Figure doc={reflectLoop} />

---

## How It Works

Unlike simple retrieval, reflect is an **agentic system** that:

1. **Autonomously gathers evidence** — The agent decides what information it needs and calls appropriate tools
2. **Uses hierarchical retrieval** — Checks mental models first, then observations, then raw facts
3. **Applies disposition** — Shapes reasoning based on the bank's personality traits
4. **Enforces applicable directives** — Global rules and rules matching the reflect tag scope
5. **Cites sources** — Returns which memories and observations were used

### The Agentic Loop

The reflect agent runs in a loop with access to these tools:

| Tool | Purpose | Priority |
|------|---------|----------|
| `search_mental_models` | User-curated summaries — best matches in full up to a token budget, a snippet of the others | Highest (check first) |
| `read_mental_models` | Full text of the pages returned as snippets | When a snippet looks relevant |
| `search_observations` | Consolidated knowledge | High |
| `recall` | Raw facts (ground truth) | Fallback |
| `expand` | Get more context for a memory | As needed |
| `done` | Complete with final answer | When ready |

The agent:
- **Must gather evidence** before answering (guardrail prevents empty responses)
- **Runs up to 10 iterations** to find relevant information
- **Validates citations** — only IDs that were actually retrieved can be cited

### Hierarchical Retrieval Strategy

The agent uses a smart retrieval hierarchy:

1. **[Mental Models](/developer/api/mental-models)** — User-curated summaries you've pre-computed for common queries
2. **[Observations](/developer/observations)** — Consolidated knowledge with freshness awareness
3. **Raw Facts** — Ground truth for verification when observations are stale

**Mental models** are saved reflect responses that you create for frequently asked questions. They're checked first because they represent explicitly curated knowledge. See the [Mental Models API](/developer/api/mental-models) for how to create and manage them.

If an observation is marked as **stale**, the agent automatically verifies it against current facts.

---

## Why Reflect?

Most AI systems can retrieve facts, but they can't **reason** about them in a consistent way.

### The Problem

Without reflect:
- **No consistent character**: Same question gets different answers each time
- **No knowledge synthesis**: System never connects related facts
- **No reasoning context**: Responses don't reflect accumulated knowledge
- **Generic responses**: Every AI sounds the same

### The Value

With reflect:
- **Consistent character**: A "detail-oriented, cautious" bank emphasizes risks and thorough planning
- **Evolving knowledge**: Observations strengthen and adapt as evidence accumulates
- **Contextual reasoning**: "Based on what I know about your team's remote work success..."
- **Differentiated behavior**: Support bots sound diplomatic, code reviewers sound direct

### When to Use Reflect

| Use `recall()` when... | Use `reflect()` when... |
|------------------------|-------------------------|
| You need raw facts | You need reasoned interpretation |
| You're building your own reasoning | You want disposition-consistent responses |
| You need maximum control | You want the bank to "think" for itself |
| Simple fact lookup | Forming recommendations |

**Example:**
- `recall("Alice")` → Returns all Alice facts and relevant mental models
- `reflect("Should we hire Alice?")` → Agent gathers evidence about Alice, reasons about fit, returns answer with citations

---

## Disposition Traits

When you create a memory bank, you can configure its disposition using three traits. These traits influence how the bank interprets information and reasons during `reflect()`:

| Trait | Scale | Low (1) | High (5) |
|-------|-------|---------|----------|
| **Skepticism** | 1-5 | Trusting, accepts information at face value | Skeptical, questions and doubts claims |
| **Literalism** | 1-5 | Flexible interpretation, reads between the lines | Literal interpretation, takes things at face value |
| **Empathy** | 1-5 | Detached, focuses on facts | Empathetic, considers emotional context |

### Mission: Natural Language Identity

Beyond numeric traits, you can provide a natural language **mission** that describes the bank's identity and reasoning context:

<CodeSnippet code={memoryBanksPy} section="bank-with-disposition" language="python" />

The reflect mission frames how the agent reasons and responds:
- Provides identity context: who the agent is and what it cares about
- Shapes how disposition traits are applied in practice
- Keeps reasoning consistent across conversations

:::info Per-operation missions
The reflect mission only affects `reflect()`. To steer what gets extracted during `retain()`, use [`retain_mission`](/developer/api/memory-banks#retain-configuration). To control what gets synthesised into observations, use [`observations_mission`](/developer/api/memory-banks#observations-configuration).
:::

---

## Disposition Shapes Reasoning

Two banks with different dispositions, given identical facts about remote work:

**Bank A** (low skepticism, high empathy):
> "Remote work enables flexibility and work-life balance. The team seems happier and more productive when they can choose their environment."

**Bank B** (high skepticism, low empathy):
> "Remote work claims need verification. What are the actual productivity metrics? The anecdotal benefits may not translate to measurable outcomes."

**Same facts → Different conclusions** because disposition shapes interpretation.

---

## Disposition Presets by Use Case

Different use cases benefit from different disposition configurations:

| Use Case | Recommended Traits | Why |
|----------|-------------------|-----|
| **Customer Support** | skepticism: 2, literalism: 2, empathy: 5 | Trusting, flexible, understanding |
| **Code Review** | skepticism: 4, literalism: 5, empathy: 2 | Questions assumptions, precise, direct |
| **Legal Analysis** | skepticism: 5, literalism: 5, empathy: 2 | Highly skeptical, exact interpretation |
| **Therapist/Coach** | skepticism: 2, literalism: 2, empathy: 5 | Supportive, reads between lines |
| **Research Assistant** | skepticism: 4, literalism: 3, empathy: 3 | Questions claims, balanced interpretation |

---

## Directives: Hard Rules

While disposition traits *influence* reasoning style, **directives** are hard
rules that the agent *must* follow when they apply to the current reflect scope.
Untagged directives are global; tagged directives require matching reflect
tags. See [Memory Banks: Directives](/developer/api/memory-banks#directives) for
the full matching and default-behavior tables.

### When to Use Directives

Use directives for constraints that must never be violated:

- **Compliance rules**: "Never recommend specific stocks or financial products"
- **Privacy constraints**: "Never share personal data with third parties"
- **Style requirements**: "Always respond in formal English"
- **Domain guardrails**: "Always cite sources when making factual claims"

### Directive Scope and Tags

Directive `tags` scope when a directive applies, just like they scope memories. Untagged directives always apply; tagged directives apply only when the `reflect` request carries matching tags. A `reflect` with no tags therefore applies only the untagged directives. Set `apply_all_directives: true` on the request to enforce **every** active directive regardless of tags. See [Memory Banks: Directive Scope and Tags](/developer/api/memory-banks#directive-scope-and-tags).

### Directives vs Disposition

| Aspect | Disposition | Directives |
|--------|-------------|------------|
| **Nature** | Soft influence | Hard rules |
| **Effect** | Shapes interpretation and tone | Must be followed exactly |
| **Violation** | Acceptable (it's a tendency) | Not acceptable |
| **Example** | High skepticism → questions claims | "Never make medical diagnoses" |

:::tip
Use disposition for personality and character. Use directives for compliance and guardrails.
:::

See [Memory Banks: Directives](/developer/api/memory-banks#directives) for how to create and manage directives.

---

## What You Get from Reflect

When you call `reflect()`:

**Returns:**
- **Response text** — Disposition-influenced answer from the agent
- **based_on** — Evidence used: memories, mental models, and directives that grounded the response
- **trace** — Tool calls, LLM calls, and observations accessed (when `include.tool_calls=True`)
- **structured_output** — Parsed response if `response_schema` was provided
- **usage** — Token usage metrics

**Example:**
```json
{
  "text": "Based on Alice's ML expertise and her work at Google, she'd be an excellent fit for the research team lead position...",
  "based_on": {
    "memories": [
      {"id": "mem-123", "text": "Alice has 5 years of ML experience", "type": "world"},
      {"id": "mem-456", "text": "Alice worked at Google on search ranking", "type": "experience"}
    ],
    "mental_models": [],
    "directives": [
      {"id": "dir-001", "name": "Formal Language", "rules": ["Always respond in formal English"]}
    ]
  },
  "usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000}
}
```

The agent automatically gathers evidence, validates citations, and generates a grounded response.

---

## Structured Output

Reflect returns prose by default. Pass a `response_schema` (a JSON Schema **object** with a
`properties` map) to *also* get a machine-readable version of the same answer:

```json
{
  "query": "How does Alice fit the research lead role?",
  "response_schema": {
    "type": "object",
    "properties": {
      "recommendation": { "type": "string" },
      "strengths": { "type": "array", "items": { "type": "string" } },
      "confidence": { "type": "number" }
    },
    "required": ["recommendation"]
  }
}
```

The response then carries **both** the prose and the structured view:

```json
{
  "text": "Based on Alice's ML expertise and her work at Google, she'd be an excellent fit...",
  "structured_output": {
    "recommendation": "Strong fit for research lead",
    "strengths": ["5 years of ML experience", "Search ranking at Google"],
    "confidence": 0.9
  }
}
```

**How it works.** The agent first reasons to a natural-language answer, then a second pass
extracts that answer into JSON matching your schema. `structured_output` is therefore always a
faithful projection of `text` — you get the readable answer **and** a typed object to program
against, never one instead of the other.

**When extraction fails.** The reflect still returns `200` with the prose answer, no
`structured_output`, and a `structured_output_error` field saying why (a provider error, a timeout,
unparseable output). A missing `structured_output` *without* that field means the answer simply held
nothing matching your schema — so you can retry the broken case without retrying the ordinary one.

**Schema rules.** The schema must be an object with at least one property. Each property's `type`
is one of `string`, `number`, `integer`, `boolean`, `array`, or `object`, and `required` lists
property names. Invalid schemas are rejected before the call runs, so a malformed schema fails
fast instead of silently returning nothing.

### Structured output for mental models

A pinned mental model can carry its own `response_schema` in its trigger config. Every refresh
then stores a `structured_output` next to the markdown content — extracted from the **final
stored document**. This matters for delta-mode (surgical) refreshes: the structured view reflects
the whole merged document, not just the facts that changed in that refresh.

### Building a schema (no code)

In the control plane, the reflect view and the mental-model dialogs include a **Build schema**
editor with a **Visual** mode (add fields — name, type, required, description) and a **Code** mode
(raw JSON). The page itself shows only whether a schema is set; all editing happens in the
builder, so you never hand-write JSON unless you want to.

---

## Why Disposition Matters

Without disposition, all AI assistants sound the same. With disposition:

- **Customer support bots** can be diplomatic and empathetic
- **Code review assistants** can be direct and thorough
- **Creative assistants** can be open to unconventional ideas
- **Risk analysts** can be appropriately cautious

Disposition creates **consistent character** across conversations while observations **evolve with evidence**.

---

## Next Steps

- [**Observations**](./observations) — How knowledge is consolidated
- [**Retain**](./retain) — How rich facts are stored
- [**Recall**](./retrieval) — How multi-strategy search works
- [**Reflect API**](./api/reflect) — Code examples and parameters

---

### Hindsight Docs Docs Developer Retain (`hindsight-docs/docs/developer/retain.md`)

---
sidebar_position: 2
---

import Figure from '@site/src/components/Figure';
import retainFigure from '@site/figures/retain.json';

# Retain: How Hindsight Stores Memories

When you call `retain()`, Hindsight transforms conversations and documents into structured, searchable memories that preserve meaning and context.

## What Retain Does

<Figure doc={retainFigure} />

---

## Rich Fact Extraction

Hindsight doesn't just store what was said — it captures **why**, **how**, and **what it means**.

### What Gets Captured

When you retain "Alice joined Google last spring and was thrilled about the research opportunities", Hindsight extracts:

**The core facts:**
- Alice joined Google
- This happened last spring

**The emotions and meaning:**
- She was thrilled
- It represented an important opportunity

**The reasoning:**
- She chose it for the research opportunities

This rich extraction means you can later ask "Why did Alice join Google?" and get a meaningful answer, not just "she joined Google."

### Preserving Context

Traditional systems fragment information:
- "Bob suggested Summer Vibes"
- "Alice wanted something unique"
- "They chose Beach Beats"

Hindsight preserves the full narrative:
- "Alice and Bob discussed naming their summer party playlist. Bob suggested 'Summer Vibes' because it's catchy, but Alice wanted something unique. They ultimately decided on 'Beach Beats' for its playful tone."

This means search results include the full context, not disconnected fragments.

---

## Two Types of Facts

Every fact is classified by **whose perspective it captures** — the agent that owns the bank, or the outside world:

| Type           | What it captures                                                              | Example |
|----------------|------------------------------------------------------------------------------|---------|
| **experience** | The bank's own agent acting, observing, or interacting — its first-person history | "I recommended Python to Alice" |
| **world**      | Facts about other people, places, things, and events                          | "Alice works at Google" |

The split is decided by **who is speaking**, not by grammar. A first-person statement is an `experience` only when the speaker *is* the bank's agent. The same words said by someone else are a `world` fact about that person:

- Agent's own log — "I patched the auth bug" → **experience** (the agent did it).
- A user talking to the agent — "I bought a Tesla" → **world** (a fact about the *user*, not the agent).

**Describe the speaker in each item's `context`** to steer this correctly. When retaining transcripts or third-party content, a context like *"Customer Maria is speaking"* ensures her first-person statements are stored as `world` facts about Maria rather than mistaken for the agent's own experiences. For the agent's own logs, a context like *"The assistant is speaking"* attributes its first-person statements to the agent as `experience` facts.

**Note:** Observations are consolidated automatically in the background after `retain()` operations complete. This consolidation process synthesizes patterns from new facts into the bank's knowledge base.

---

## Entity Recognition

Hindsight automatically identifies and tracks **entities** — the people, organizations, and concepts that matter.

### What Gets Recognized

- **People:** "Alice", "Dr. Smith", "Bob Chen"
- **Organizations:** "Google", "MIT", "OpenAI"
- **Places:** "Paris", "Central Park", "California"
- **Products & Concepts:** "Python", "TensorFlow", "machine learning"

### Entity Resolution

The same entity mentioned different ways gets unified through **fuzzy name matching**, reinforced by co-occurrence and temporal proximity:
- "Alice" + "Alice Chen" + "Alice C." → one person

Because resolution keys off name similarity, close variants merge automatically. Names that do not resemble each other (a nickname and an unrelated formal name, for example) are not unified on the name alone, though shared co-occurring entities can still link them.

**Why it matters:** You can ask "What do I know about Alice?" and get everything, even if she was mentioned as "Alice Chen" in some conversations.

Resolution is a judgement call, so it can go the other way too: on a bank with a lot of history, a short new name that resembles an existing entity — and that turns up alongside entities the existing one is already associated with — can be absorbed into it instead of becoming its own entity. If you see facts about a new person attached to an unrelated entity, [How entity resolution decides](/developer/configuration#how-entity-resolution-decides) explains what is being compared and which setting makes matching stricter.

### Context-Aware Disambiguation

If "Alice" appears with "Google" and "Stanford" multiple times, a new "Alice" mentioning those is likely the same person. Hindsight uses co-occurrence patterns to disambiguate common names.

### Entity Labels

You can define a controlled vocabulary of `key:value` classification labels (e.g. `pedagogy:scaffolding`, `engagement:active`) that are extracted at retain time and stored as entities. Because labels become entities, they automatically link related memories in the knowledge graph and improve both semantic and keyword retrieval. Labels can optionally also write to the memory unit's tags, enabling standard tag-based filtering during recall and reflect.

Unlike regular entities, label entities never merge by name similarity — distinct label values must stay distinct, so they resolve by exact match only and are excluded from fuzzy name matching altogether.

See [entity_labels in the bank config](/developer/api/memory-banks#entity-labels) for full configuration details.

---

## Building Connections

Memories aren't isolated — Hindsight creates a **knowledge graph** with four types of connections:

### Entity Connections

All facts mentioning the same entity are linked together.

**Enables:** "Tell me everything about Alice" → retrieves all Alice-related facts

### Time-Based Connections

Facts close in time are connected, with stronger links for closer dates.

**Enables:** "What else happened around then?" → finds contextually related events

### Meaning-Based Connections

Semantically similar facts are linked, even if they use different words.

**Enables:** "Tell me about similar topics" → finds thematically related information

### Causal Connections

Cause-effect relationships are explicitly tracked.

**Enables:** "Why did this happen?" → trace reasoning chains
**Example:** "Alice felt burned out" ← caused by ← "She worked 80-hour weeks"

---

## Understanding Time

Hindsight tracks **two temporal dimensions**:

### When It Happened

For events (meetings, trips, milestones), Hindsight records when they occurred.
- "Alice got married in June 2024" → occurred in June 2024

For general facts (preferences, characteristics), there's no specific occurrence time.
- "Alice prefers Python" → ongoing preference

### When You Learned It

Hindsight also tracks when you told it each fact.

**Why both?**

Imagine in January 2025, someone tells you "Alice got married in June 2024":
- **Historical queries** work: "What did Alice do in 2024?" → finds the marriage
- **Recency ranking** works: Recent mentions get priority in search
- **Temporal reasoning** works: "What happened before her marriage?" → finds earlier events

Without this distinction, old information would either be unsearchable by date or treated as irrelevant.

---

## Tagging Memories

Tags enable visibility scoping—useful when one memory bank serves multiple users but each should only see relevant memories.

- **Item tags**: Tag individual memories with specific scopes
- **Document tags**: Apply tags to all items in a batch
- **Tag filtering**: Filter during recall/reflect by tags

See [Retain API](./api/retain) for code examples and [Recall API](./api/recall) for filtering options.

---

## Images and Files in Your Content

A lot of what a document actually says lives in its pictures: the screenshot of
the button an instruction refers to, the diagram holding an escalation path, the
chart that *is* the data. `content` accepts an ordered list of blocks so those sit
where they belong, instead of being summarised into a caption beforehand:

```json
{
  "content": [
    {"type": "text",  "text": "To reset the VPN, click the button shown:"},
    {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}},
    {"type": "text",  "text": "...then reconnect."}
  ]
}
```

A plain string still works exactly as before — nothing about text-only retain
changes.

The point is **position**. Extraction sends the text and the pictures together,
in order, so the model reads the screenshot beside the sentence that introduces
it. "Click the button shown below" is meaningless on its own; next to the
picture it becomes a fact that names the button.

### What comes back

Facts read naturally — `[image: image/png]` marks where an attachment was, never
a content hash — and each memory carries the attachments **it was drawn from**,
with a URL you can fetch:

```json
{
  "text": "The escalation path for a stuck sync begins by contacting Tier 3 Platform.",
  "attachments": [
    {"id": "c414cd0e204d", "kind": "image", "media_type": "image/png",
     "url": "/v1/default/banks/my-bank/attachments/c414cd0e204d"}
  ]
}
```

Facts that came from the prose have no `attachments`, even when the same document
is full of pictures. So an attachment shown next to a memory means the model
looked at it to produce that memory — it is evidence, not decoration.

An observation carries the attachments of the facts it was consolidated from, so
a screenshot still reaches you when recall or reflect answers from the
observation rather than the raw fact. Reflect's `based_on` (with
`include.facts`) returns each cited memory with its `attachments`, plus the
`document_id`, `chunk_id`, `tags` and `metadata` it was stored with.

### What to expect from charts and tables

An attachment carrying structured data is transcribed rather than summarised:
each row, bar or labelled value becomes its own fact, carrying its label, its
figure, and how it is drawn. A chart therefore produces many more memories than a
screenshot does — that is deliberate, since a summary of a twenty-bar chart keeps
three values and silently drops seventeen, and later questions ask about the ones
it dropped.

Very dense pages are still sampled rather than exhausted: a single extraction pass
over an infographic listing a hundred entries will capture a large share of them,
not all of them.

### Requirements

- A model that can read images. By default that is the bank's retain model, but
  it does not have to be: `HINDSIGHT_API_VLM_MODEL` names a **vision slot** used
  only for the chunks that actually carry an attachment, leaving every text-only
  chunk on the retain LLM. So a bank whose documents are mostly prose can keep a
  cheap text model and pay for vision only where there is something to look at.
- If that model cannot read images — or if Hindsight cannot tell, which is the
  case for gateway backends serving mixed catalogues — the retain is refused
  with `422` rather than dropping the attachment silently. See
  [`HINDSIGHT_API_LLM_VISION`](/developer/configuration#llm-provider).
- Batch retain (`HINDSIGHT_API_RETAIN_BATCH_ENABLED`) cannot carry attachments,
  and also refuses with `422`.

This is different from [`POST /files/retain`](/developer/configuration#file-processing),
which converts a whole file to markdown as its **own** document. That is still the
right tool for a scanned report you want parsed — but it separates the file from
the prose that referred to it, which is exactly what inline attachments avoid.

For storage, size limits and accepted media types, see
[Inline attachments in retain](/developer/configuration#inline-attachments-in-retain).

---

## What You Get

After `retain()` completes:

- **Structured facts** that preserve meaning, emotions, and reasoning
- **Unified entities** that resolve different name variations
- **Knowledge graph** with entity, temporal, semantic, and causal links
- **Temporal grounding** for both historical and recency-based queries
- **Optional tags** for filtering during recall

All stored in your isolated **memory bank**, ready for `recall()` and `reflect()`.

---

## Steering Extraction with a Mission

By default, `retain()` extracts all significant facts from the content. You can narrow this focus with a **retain mission** (`retain_mission`) — a plain-language description of what this bank should pay attention to.

```
e.g. Always include technical decisions, API design choices, and architectural trade-offs.
     Ignore meeting logistics, greetings, and social exchanges.
```

The mission is injected into the extraction prompt alongside the built-in rules — it steers the LLM without replacing the extraction logic. It works with the LLM-based extraction modes (`concise`, `verbose`, `verbatim`, `custom`) and is ignored in `chunks` mode.

For finer control, you can also change the **extraction mode**:

| Mode | When to use |
|------|-------------|
| `concise` *(default)* | General-purpose — selective, fast |
| `verbose` | When you need richer facts with full context and relationships |
| `custom` | When you want to write your own extraction rules entirely |
| `verbatim` | When you need the original chunk text preserved, with LLM-extracted metadata such as entities and dates |
| `chunks` | When you want to store chunks as-is with no LLM call or extracted metadata |

Set `retain_mission` and `retain_extraction_mode` via the [bank config API](/developer/api/memory-banks#retain-configuration), or with the [`HINDSIGHT_API_RETAIN_MISSION`](/developer/configuration#retain) and [`HINDSIGHT_API_RETAIN_EXTRACTION_MODE`](/developer/configuration#retain) environment variables.

### When a mission excludes everything in a document

A mission narrows what becomes a memory — and content that produces no facts produces no memories at all. The document itself is still stored, but `recall` and `reflect` search memories, so a document with zero memories cannot be found by either. Tightening a mission therefore trades away retrieval of the raw source, not just fact creation.

This is a normal outcome, not an error: the retain succeeds and the operation is reported as completed. Two signals tell you it happened:

| Where | What to look for |
|-------|------------------|
| [`retain.completed` webhook](/developer/api/webhooks#retaincompleted) | `data.memory_unit_count: 0` |
| [Metrics](/developer/monitoring#retain-metrics) | `hindsight.retain.documents.total{outcome="no_facts"}` |

You can also audit after the fact: `GET /documents` returns `memory_unit_count` per document, so filtering for `0` lists everything currently unreachable.

Extraction is not fully deterministic — a borderline document can yield facts on one run and none on the next. Treat a zero as "this document needs another pass" rather than as a permanent verdict.

To recover a document, widen the mission and reprocess it — the stored text is re-extracted, and no re-upload is needed:

```
POST /v1/default/banks/{bank_id}/documents/{document_id}/reprocess
```

---

## Observation Consolidation

After `retain()` completes, Hindsight automatically triggers **observation consolidation** in the background. This process:

1. Analyzes new facts against existing observations
2. Creates new observations when patterns emerge
3. Refines existing observations with new evidence
4. Tracks which facts support each observation

This happens asynchronously — your `retain()` call returns immediately while consolidation runs in the background.

See [Observations](./observations) for details on how consolidation works.

---

## Memory Defense and Source Provenance

### receipt_uri (optional)

Type: `string`.

Optional pointer into an external receipt or co-signature system. Stored as-is and surfaced in `security_events.receipt_uri` for any Memory Defense decision on this item.

### 422 — Memory Defense violation

When Memory Defense is enabled on the target bank and **every** item in the batch is blocked by policy, the request returns 422 with a violation list:

```json
{
  "detail": {
    "violations": [
      { "index": 0, "detector": "prompt_injection", "severity": "high", "message": "..." }
    ]
  }
}
```

Partial-block batches return 200 with the un-blocked items processed; blocked items are silently dropped from the result with their decisions recorded in `security_events`.

See [Memory Defense](./memory-defense/index.md) for the full guide.

---

## Next Steps

- [**Observations**](./observations) — How knowledge is consolidated after retain
- [**Recall**](./retrieval) — How multi-strategy search retrieves relevant memories
- [**Reflect**](./reflect) — How the agentic loop uses observations
- [**Retain API**](./api/retain) — Code examples and parameters

---

### Hindsight Docs Docs Developer Retrieval (`hindsight-docs/docs/developer/retrieval.md`)

---
sidebar_position: 3
---

import Figure from '@site/src/components/Figure';
import tempr from '@site/figures/tempr.json';

# Recall: How Hindsight Retrieves Memories

When you call `recall()`, Hindsight uses multiple search strategies in parallel to find the most relevant memories, regardless of how you phrase your query.

<Figure doc={tempr} />

---

## The Challenge of Memory Recall

Different queries need different search approaches:

- **"Alice works at Google"** → needs exact name matching
- **"Where does Alice work?"** → needs semantic understanding
- **"What did Alice do last spring?"** → needs temporal reasoning
- **"Why did Alice leave?"** → needs causal relationship tracing

No single search method handles all these well. Hindsight runs **four complementary strategies in parallel** and fuses their results.

---

## Four Search Strategies

### Semantic Search

**What it does:** Understands the *meaning* behind words, not just the words themselves.

**Best for:**
- Conceptual matches: "Alice's job" → "Alice works as a software engineer"
- Paraphrasing: "Bob's expertise" → "Bob specializes in machine learning"
- Synonyms: "meeting" matches "conference", "discussion", "gathering"

**Why it matters:** You can ask questions naturally without matching exact keywords.

---

### Keyword Search

**What it does:** Finds exact terms and names, even when they're spelled uniquely.

**Best for:**
- Proper nouns: "Google", "Alice Chen", "MIT"
- Technical terms: "PostgreSQL", "HNSW", "TensorFlow"
- Unique identifiers: URLs, product names, specific phrases

**Why it matters:** Ensures you never miss results that mention specific names or terms, even if they're semantically distant from your query.

**Backends:** Hindsight ships five pluggable BM25 backends, selected via
`HINDSIGHT_API_TEXT_SEARCH_EXTENSION`:

| Backend | What it uses | Citus-compatible? |
|---|---|---|
| `native` | PostgreSQL `tsvector` + `ts_rank_cd` (TF-IDF, not true BM25) | Yes |
| `vchord` | `vchord_bm25` extension | No |
| `pg_textsearch` | Timescale `pg_textsearch` extension | No |
| `pgroonga` | PGroonga (Groonga) full-text extension, `TokenBigram` polyglot tokenizer | No |
| `pg_search` | ParadeDB `pg_search` extension, configurable tokenizer (e.g. `jieba`, `chinese_compatible`, `ngram`) via `HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER` | Yes |

If you need true BM25 ranking on a horizontally scaled Postgres (Citus) cluster,
`pg_search` is the only option. See the [`pg_search` docker-compose example](https://github.com/vectorize-io/hindsight/tree/main/docker/docker-compose/pg_search).

---

### Graph Traversal

**What it does:** Follows connections between entities to find indirectly related information.

**Best for:**
- Indirect relationships: "What does Alice do?" → Alice → Google → Google's products
- Entity exploration: "Bob's colleagues" → Bob → co-workers → shared projects
- Multi-hop reasoning: "Alice's team's achievements"

**Why it matters:** Retrieves facts that aren't semantically or lexically similar but are **structurally connected** through the knowledge graph.

**Example:** Even if Alice and her manager are never mentioned together, graph traversal can find the manager through shared projects or team relationships.

---

### Temporal Search

**What it does:** Understands time expressions and filters by when events occurred.

**Best for:**
- Historical queries: "What did Alice do in 2023?"
- Time ranges: "What happened last spring?"
- Relative time: "What did Bob work on last year?"
- Before/after: "What happened before Alice joined Google?"

**How it works:** When the query contains a time reference, Hindsight parses it into a date window, then retrieves the memories whose time overlaps that window. Within the window it selects candidates by **semantic relevance to the query** — not by recency — so the most relevant in-window memory is never dropped just because other memories happen to be more recent. It then **spreads the selection across the window's range**: the window is divided into time-buckets and the strongest match from each populated bucket is taken first, so a "what happened in 2023?" query surfaces memories from across the whole year rather than clustering on whichever stretch is densest. Time is also used as a *scoring* signal — memories closer to the center of the window get a small boost (see [Temporal proximity signal](#temporal-proximity-signal)).

**Why it matters:** Enables precise historical queries that stay relevant *and* representative of the whole period. It also stays fast and meaningful on memory banks whose timestamps are densely clustered (for example when a large batch is ingested with one date): because selection is relevance-first, a time window that happens to match most of the bank still returns the best matches rather than an arbitrary slice.

---

## Result Fusion

After the four strategies run, results are **fused together**:

- Memories appearing in **multiple strategies** rank higher (consensus)
- **Rank matters more than score** (robust across different scoring systems)
- Final results are **re-ranked** using a neural model that considers query-memory interaction

**Why fusion matters:** A fact that's both semantically similar AND mentions the right entity will rank higher than one that's only semantically similar.

---

## Why Multiple Strategies?

Consider the query: **"What did Alice say about Python last spring?"**

- **Semantic** finds facts about Alice's views on programming
- **Keyword** ensures "Python" is actually mentioned
- **Graph** connects Alice → programming languages → related entities
- **Temporal** filters to "last spring" timeframe

The **fusion** of all four gives you exactly what you're looking for, even though no single strategy would suffice.

---

## Token Budget Management

Hindsight is built for AI agents, not humans. Traditional search systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.

**How it works:**
- Top-ranked memories selected first
- Stops when token budget is exhausted
- You specify context budget, Hindsight fills it with the most relevant memories

**Parameters you control:**
- `max_tokens`: How much memory content to return (default: 4096 tokens)
- `budget`: Search depth level (low, mid, high)
- `types`: Filter by world, experience, observation, or all
- `tags`: Filter memories by visibility tags
- `tags_match`: How to match tags (see [Recall API](./api/recall) for all options)

### Expanding Context: Chunks

Memories are distilled facts—concise but sometimes missing nuance. When your agent needs deeper context, you can optionally retrieve the source material:

**Chunks** return the raw text that generated each memory—useful when the distilled fact loses important nuance:

```
Memory: "Alice prefers Python over JavaScript"
Chunk:  "Alice mentioned she prefers Python over JavaScript, mainly because
         of its data science ecosystem, though she admits JS is better for
         frontend work and she's been learning TypeScript lately."
```

Use `include_chunks=True` with `max_chunk_tokens` to control the token budget for chunks. This is useful when generating responses that need verbatim quotes or when context matters (e.g., "What exactly did Alice say about the project?").

---

## Tuning Recall: Quality vs Latency

Different use cases require different trade-offs between **recall quality** and **response speed**. Two parameters control this:

### Budget: Search Depth

Controls how thoroughly Hindsight explores the memory bank—affecting graph traversal depth, candidate pool size, and cross-encoder re-ranking:

| Budget | Best For | Trade-off |
|--------|----------|-----------|
| **low** | Quick lookups, simple queries | Fast, may miss indirect connections |
| **mid** | Most queries, balanced | Good coverage, reasonable speed |
| **high** | Complex queries requiring deep exploration | Thorough, slower |

**Example:** "What did Alice's manager's team work on?" benefits from high budget to traverse multiple hops (Alice → manager → team → projects) and evaluate more candidates.

### Max Tokens: Context Window Size

Controls how much memory content to return:

| Max Tokens | ~Pages of Text | Best For | Trade-off |
|------------|----------------|----------|-----------|
| **2048** | ~2 pages | Focused answers, fast LLM | Fewer memories, faster |
| **4096** (default) | ~4 pages | Balanced context | Good coverage, standard |
| **8192** | ~8 pages | Comprehensive context | More memories, slower LLM |

**Example:** "Summarize everything about Alice" benefits from higher max_tokens to include more facts.

### Two Independent Dimensions

Budget and max_tokens control different aspects of recall:

| Parameter | What it controls | Latency impact | Example |
|-----------|------------------|----------------|---------|
| **Budget** | How thoroughly to explore memories | Search time | High budget finds Alice → manager → team → projects |
| **Max Tokens** | How much context to return | LLM processing time | High tokens returns more memories to the agent |

**They're independent.** Common combinations:

| Budget | Max Tokens | Use Case |
|--------|------------|----------|
| high | low | Deep search, return only the best results |
| low | high | Quick search, return everything found |
| high | high | Comprehensive research queries |
| low | low | Fast chatbot responses |

### Recommended Configurations

| Use Case | Budget | Max Tokens | Why |
|----------|--------|------------|-----|
| **Chatbot replies** | low | 2048 | Fast responses, focused context |
| **Document Q&A** | mid | 4096 | Balanced coverage and speed |
| **Research queries** | high | 8192 | Comprehensive, multi-hop reasoning |
| **Real-time search** | low | 2048 | Minimize latency |

---

## Scoring & Ranking Deep Dive

This section explains exactly how Hindsight turns raw retrieval results into a final ranked list. The pipeline has three stages: **RRF fusion**, **cross-encoder reranking**, and **combined scoring**.

### Stage 1: Reciprocal Rank Fusion (RRF)

After all strategies run in parallel, their results are merged using [Reciprocal Rank Fusion](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf). RRF combines ranked lists by rewarding items that appear highly ranked across multiple strategies, without relying on raw scores (which aren't comparable across different retrieval methods).

**Formula:**

```
score(d) = Σ  1 / (k + rank_i(d))
           i
```

Where:
- **k = 60** (smoothing constant — prevents top-ranked items from dominating)
- **rank_i(d)** = position of document *d* in strategy *i* (1-indexed)
- The sum runs over all strategies where *d* appears

**Within RRF, all four strategies are weighted equally** — fusion uses rank position, not the source, so no strategy gets an implicit multiplier. You can, however, deliberately bias a source with [`HINDSIGHT_API_RECALL_STRATEGY_BOOSTS`](./configuration): that boost is applied at a separate stage — before the reranking pre-filter cap, and again after a cross-encoder rerank — not inside the RRF fusion above. The pre-cap step runs only when the merged pool exceeds the cap, and it does not rewrite RRF scores. A passthrough reranker (mode `rrf`, an `rrf` provider, or a failover chain that has degraded to `rrf`) skips the post-rerank nudge: within the cap the setting then does nothing, and above the cap it only changes who is kept.

**Why RRF over raw score merging?** Each retrieval strategy produces scores on a different scale (cosine similarity, BM25 tf-idf, graph activation). These scores aren't comparable — a BM25 score of 12.5 and a cosine similarity of 0.85 don't mean the same thing. RRF sidesteps this by using only rank positions, making it robust across any scoring system without requiring calibration.

**Example:** A memory ranked #1 in semantic and #5 in BM25:
```
RRF score = 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318
```

A memory ranked #1 in semantic only:
```
RRF score = 1/(60+1) = 0.0164
```

The first memory ranks higher because it has **consensus** across strategies.

---

### Stage 2: Cross-Encoder Reranking

RRF gives a good initial ranking, but it's based on positions, not on deep query-document understanding. The cross-encoder evaluates each candidate against the query as a pair, producing a relevance score.

**Pre-filtering:** Before reranking, candidates are trimmed to the top **300** (by RRF score) to limit computational cost. This is configurable via `HINDSIGHT_API_RERANKER_MAX_CANDIDATES`. If [`HINDSIGHT_API_RECALL_STRATEGY_BOOSTS`](./configuration) is set, the boost is applied before this cut, so candidates from a favoured source are more likely to survive it. The boost promotes the favoured arm in *rank* space (its rank is divided by the level's divisor before the RRF contribution is computed) rather than scaling its score, so it reaches deeper into that arm without evicting the top-ranked hits of the others — including on banks whose merged pool is many times the cap. When `trace: true` is requested, the `rerank_prefilter` phase reports how many candidates were kept and dropped, the cap in force, the active boosts, and the per-arm composition of the survivors.

**Why rerank after RRF?** RRF is position-based — it knows a memory ranked well across strategies, but it never actually reads the query and the memory together. The cross-encoder does: it takes the query and each candidate as a pair and produces a relevance score based on their full interaction. This catches nuances that position-based fusion misses, like a memory that ranked #1 in keyword search because it matched a common term but is actually irrelevant to the query's intent.

**Score normalization:** Cross-encoders output raw logits (which can be negative). Scores that already fall within [0, 1] — as returned by calibrated external API rerankers (e.g. Cohere, SiliconFlow, ZeroEntropy, Alibaba, Jina) — are passed through unchanged to preserve their absolute confidence. Raw logits outside [0, 1] are normalized to [0, 1] using the sigmoid function:

```
CE_normalized = 1 / (1 + e^(-raw_logit))
```

**Batch processing:** Candidates are scored in batches — **32 pairs** for the local reranker, **128 pairs** for TEI.

:::tip No cross-encoder?
When running without a cross-encoder (e.g., slim image with no external reranker), the system falls back to RRF-derived scores: candidates are assigned synthetic scores spread across [0.1, 1.0] based on their RRF rank, so the combined scoring boosts below still work meaningfully. `HINDSIGHT_API_RECALL_STRATEGY_BOOSTS` does not add its post-rerank nudge on this path. If the merged pool is within the reranker cap, the setting changes nothing; if the pool is larger, it only changes which candidates are kept, and their order still follows these RRF-derived scores plus recency, temporal and proof.
:::

---

### Stage 3: Combined Scoring (Boosts)

The normalized cross-encoder score is adjusted by three **multiplicative boosts** that incorporate signals the cross-encoder can't see: recency, temporal proximity, and evidence strength.

**Why multiplicative instead of additive?** Additive boosts (e.g., `CE + 0.1 × recency`) would give the same absolute bonus to every candidate regardless of relevance. A barely-relevant memory could leapfrog a highly-relevant one just by being recent. Multiplicative boosts keep adjustments proportional to the base relevance score — a +10% nudge on a high-relevance memory is a bigger absolute change than +10% on a low-relevance one. This ensures secondary signals never overpower the primary relevance judgment.

**Formula:**

```
final_score = CE_normalized × recency_boost × temporal_boost × proof_count_boost
```

Each boost is centered at 1.0 (neutral) and controlled by an alpha that caps how much it can swing:

```
boost = 1 + α × (signal - 0.5)
```

| Boost | α | Max adjustment | What it rewards |
|-------|---|----------------|-----------------|
| **Recency** | 0.2 | ±10% | Recent memories over older ones |
| **Temporal proximity** | 0.2 | ±10% | Memories close to a queried time window |
| **Proof count** | 0.1 | ±5% | Observations backed by more evidence |

#### Recency signal

Linear decay over 365 days from the memory's occurrence date:

```
recency = clamp(1.0 - days_ago / 365, 0.1, 1.0)
```

A memory from the query timestamp has recency 1.0 (+10% boost). A memory from 6 months before the query timestamp has recency ~0.5 (neutral). A memory more than a year before the query timestamp has recency 0.1 (-8% penalty). If no `query_timestamp` is provided, the server's current time is used. Memories without dates get 0.5 (neutral — no boost or penalty).

#### Temporal proximity signal

Only active when the query contains a time reference (e.g., "last spring", "in 2023"). Measures how close a memory's date is to the center of the queried time window:

```
temporal_proximity = 1.0 - min(days_from_center / (window_days / 2), 1.0)
```

A memory at the center of the window gets 1.0 (+10% boost). A memory at the edge gets 0.0 (-10% penalty). For non-temporal queries, all memories get 0.5 (neutral).

#### Proof count signal

For observation-type memories, rewards those backed by more evidence using a logarithmic curve:

```
proof_norm = clamp(0.5 + ln(proof_count) / 10, 0.0, 1.0)
```

| Proof count | proof_norm | Boost |
|-------------|-----------|-------|
| 1 | 0.5 | Neutral |
| 3 | 0.61 | +1.1% |
| 10 | 0.73 | +2.3% |
| 150+ | 1.0 | +5% (max) |

#### Maximum combined range

With all boosts at their extremes:
- **Best case:** ×1.10 × 1.10 × 1.05 ≈ **+27%**
- **Worst case:** ×0.90 × 0.90 × 0.95 ≈ **-23%**

The boosts are intentionally conservative — they nudge the ranking without overriding cross-encoder relevance.

---

### Stage 4: Token Truncation

After scoring, results are sorted by `final_score` and selected top-down until the `max_tokens` budget is exhausted. Only the memory text counts toward the budget — metadata is free. A result too long for what is left of the budget is skipped and the packing continues with the next one, so a single long fact does not cost you the shorter ones ranked behind it. If nothing fits at all, the top-ranked result is still returned whole, so a query that matched something is never answered with an empty list.

---

### How Budget Maps to Pipeline Parameters

The `budget` parameter (low/mid/high) controls **search depth** — how many candidates each strategy considers. Each level maps to a **recall budget** number that flows through every pipeline stage:

| Budget | Recall budget (fixed mode) | Env var override |
|--------|---------------------------|-----------------|
| **low** | 100 | `HINDSIGHT_API_RECALL_BUDGET_FIXED_LOW` |
| **mid** | 300 (default) | `HINDSIGHT_API_RECALL_BUDGET_FIXED_MID` |
| **high** | 1000 | `HINDSIGHT_API_RECALL_BUDGET_FIXED_HIGH` |

This recall budget flows through the pipeline as follows:

| Pipeline stage | How the recall budget is used |
|----------------|-------------------------------|
| **Semantic search** | `LIMIT recall_budget` in SQL, with the ANN candidate list sized to match for the query (on pgvector, `hnsw.ef_search`, capped at that setting's maximum of 1000) |
| **BM25 search** | `LIMIT recall_budget` in SQL |
| **Graph traversal** | Explores up to recall_budget nodes |
| **Temporal spreading** | Activates up to recall_budget nodes via links |
| **Result consideration** | Top recall_budget × 2 results considered for token filtering |

Reranking pre-filter (300 candidates) is **independent** of budget — it's a separate knob (`HINDSIGHT_API_RERANKER_MAX_CANDIDATES`).

:::info Adaptive budgeting
An alternative budget mode scales the recall budget with `max_tokens` instead of using fixed values:

```
recall_budget = clamp(max_tokens × ratio, min, max)
```

| Budget | Ratio | Env var override |
|--------|-------|-----------------|
| low | 2.5% of max_tokens | `HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_LOW` |
| mid | 7.5% of max_tokens | `HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_MID` |
| high | 25% of max_tokens | `HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_HIGH` |

The result is clamped to a floor of **20** (`HINDSIGHT_API_RECALL_BUDGET_MIN`) and a ceiling of **2000** (`HINDSIGHT_API_RECALL_BUDGET_MAX`).

Enable with `HINDSIGHT_API_RECALL_BUDGET_FUNCTION=adaptive`.
:::

---

### Graph Scoring Detail

The graph traversal (link expansion) combines three independent signals additively for each candidate:

| Signal | Score formula | Range |
|--------|--------------|-------|
| **Entity overlap** | tanh(shared_entity_count × 0.5) | [0, ~1.0] |
| **Semantic link** | Precomputed kNN link weight | [0.7, 1.0] |
| **Causal link** | Causal link weight | [0, 1.0] |

```
graph_score = entity_score + semantic_score + causal_score   ∈ [0, 3]
```

The additive combination rewards **convergent evidence** — a memory connected to the query through multiple signal types ranks higher than one connected through a single strong signal.

**Why tanh for entity scores?** Raw shared-entity count is unbounded — a high-fanout entity like "user" could produce counts of 50+, drowning out the other two signals. `tanh(count × 0.5)` saturates naturally: the first few shared entities matter a lot (1→0.46, 2→0.76, 3→0.91), but additional ones contribute diminishing returns, keeping the entity signal in [0, 1] alongside semantic and causal scores.

**Why additive instead of multiplicative here?** Unlike the combined scoring boosts, graph signals are independent evidence channels, not adjustments to a base score. A memory might be connected only through causal links (no shared entities, no semantic similarity) — multiplicative combination would zero it out. Additive scoring lets each signal contribute independently, and the outer RRF fusion handles ranking across strategies.

**Entity signal example:** A memory sharing 1 entity with the query scores tanh(0.5) ≈ 0.46. Two shared entities score tanh(1.0) ≈ 0.76. Three or more saturate near 0.91+.

---

## Next Steps

- [**Retain**](./retain) — How memories are stored with rich context
- [**Reflect**](./reflect) — How disposition influences reasoning
- [**Recall API**](./api/recall) — Code examples, parameters, and tag filtering

---

### Hindsight Docs Docs Developer Services (`hindsight-docs/docs/developer/services.md`)

import Figure from '@site/src/components/Figure';
import services from '@site/figures/services.json';

# Services

Hindsight consists of three services that can run together or separately depending on your deployment needs.

<Figure doc={services} />

## API Service

The core memory engine. Handles all memory operations:

- **Retain**: Ingests content, extracts facts, builds knowledge graph
- **Recall**: Semantic search across memories
- **Reflect**: Disposition-aware answer generation

```bash
hindsight-api        # Default port: 8888
```

The API service is stateless and can be horizontally scaled behind a load balancer. All state is stored in PostgreSQL.

By default, the API also processes background tasks (mental model consolidation) internally. For high-throughput deployments, you can disable this and run dedicated workers instead.

## Worker Service

Dedicated task processor for background operations. Uses the **same package and Docker image** as the API service, just with a different entry point.

```bash
hindsight-worker     # Default metrics port: 8889
```

Workers use PostgreSQL as a task broker, polling for pending tasks. Multiple workers can run simultaneously without conflicts.

| Deployment | Internal Worker | Dedicated Workers |
|------------|-----------------|-------------------|
| **Development** | ✅ Simple, all-in-one | ❌ Overkill |
| **Small production** | ✅ Less infrastructure | ❌ Overkill |
| **High throughput** | ❌ API bottleneck | ✅ Scale independently |
| **Long-running tasks** | ❌ Blocks API resources | ✅ Isolated processing |

To use dedicated workers, disable the internal worker in the API and start worker processes:

```bash
# Disable internal worker in API
HINDSIGHT_API_WORKER_ENABLED=false hindsight-api

# Start dedicated workers (run multiple instances)
hindsight-worker --worker-id worker-1
hindsight-worker --worker-id worker-2
```

Each worker exposes `/health/live` (liveness, no database access), `/health` and
`/health/ready` (readiness, checks the database), and `/metrics` for monitoring.
See [Monitoring - Health Endpoints](./monitoring#health-endpoints).

Before scaling down or removing workers, release their tasks with `hindsight-admin decommission-worker <worker-id>`.

See [Configuration - Distributed Workers](./configuration#distributed-workers) for all worker settings and [Installation - Helm](./installation#distributed-workers) for Kubernetes deployment.

## Control Plane

Web UI for managing and exploring your memory banks:

- Browse agents and memory banks
- Explore entities and relationships
- View ingestion history and operations
- Test recall queries interactively

The Control Plane connects to the API service and provides a visual interface for development and debugging.

For bare metal deployments, you can run the Control Plane standalone using npx. See [Installation - Bare Metal](./installation#control-plane) for details.

---

### Hindsight Docs Docs Developer Storage (`hindsight-docs/docs/developer/storage.md`)

# Storage

Hindsight uses PostgreSQL as its primary storage backend, with Oracle AI Database available as an alternative for enterprise deployments.

## Why PostgreSQL?

PostgreSQL provides all capabilities required for a semantic memory system in a single database:

| Capability | Implementation |
|------------|----------------|
| Vector search | pgvector extension with HNSW indexes |
| Full-text search | Built-in tsvector with GIN indexes |
| Relational data | Native PostgreSQL |
| JSON documents | JSONB with indexing |
| Graph queries | Recursive CTEs |

### Reduced System Dependencies

Building exclusively for PostgreSQL simplifies deployment and operations:

- Single connection string to configure
- Single backup and restore strategy
- Single monitoring target
- ACID transactions across all data types
- Single upgrade path

### No Storage Abstraction

Hindsight does not abstract storage behind a generic interface. This is a deliberate trade-off.

We believe PostgreSQL is becoming the standard database API. Its popularity, extension ecosystem, and modularity mean that PostgreSQL-compatible interfaces are appearing everywhere—from serverless offerings to distributed databases. Building for PostgreSQL today means compatibility with a growing ecosystem tomorrow.

Supporting multiple databases would increase flexibility but conflict with our core goals: Hindsight is fully open source and designed to be as simple as possible to run and use. Adding database abstractions introduces complexity in code, testing, documentation, and operations—complexity that we pass on to users.

By building on PostgreSQL, we keep the system simple:
- One set of deployment instructions
- One set of performance characteristics to understand
- One codebase optimized for one backend
- No configuration decisions about which database to use

### Oracle AI Database Support

For enterprise deployments, Hindsight also supports Oracle AI Database with full feature parity. All memory operations—retain, recall, and reflect—work identically on Oracle, making it a drop-in option for organizations that standardize on Oracle infrastructure.

See the [Oracle Database guide](./oracle) for setup: prerequisites, provisioning, connection URLs, migrations, and the differences from PostgreSQL.

## Development with pg0

For local development, Hindsight uses **[pg0](https://github.com/vectorize-io/pg0)**—an embedded PostgreSQL distribution.

### What is pg0?

pg0 is a single binary containing:
- PostgreSQL server
- pgvector extension (pre-installed)
- Automatic initialization

### Behavior

When no `HINDSIGHT_API_DATABASE_URL` is configured, Hindsight:
1. Starts an embedded PostgreSQL instance on port 5555
2. Initializes the schema
3. Stores data in `~/.hindsight/pg0/`

### Environments

| Environment | Database | Configuration |
|-------------|----------|---------------|
| Development | pg0 (embedded) | Automatic |
| Production | PostgreSQL 15+ | `HINDSIGHT_API_DATABASE_URL` environment variable |

## Requirements

- PostgreSQL 15 or later
- pgvector 0.5.0 or later

Any PostgreSQL instance that satisfies these requirements should work. If you encounter issues with a specific setup, [open a GitHub issue](https://github.com/hindsight-ai/hindsight/issues).

### Tested Managed Services

- AWS RDS (PostgreSQL 15+)
- Google Cloud SQL
- Azure Database for PostgreSQL
- Supabase
- Neon

---

### Hindsight Docs Docs Sdks Cli (`hindsight-docs/docs/sdks/cli.mdx`)

---
sidebar_position: 4
---

# CLI Reference

import CodeSnippet from '@site/src/components/CodeSnippet';
import cliSh from '!!raw-loader!@site/examples/api/cli-reference.sh';

The Hindsight CLI provides command-line access to memory operations and bank management. All commands follow the [OpenAPI specification](/api-reference), so you can use `--help` on any command to see all available options.

## Installation

```bash
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
```

## Configuration

Configure the API URL:

<CodeSnippet code={cliSh} section="cli-configure" language="bash" />

You can also run `hindsight configure` with no flags to be prompted interactively, or use environment variables (highest priority):

```bash
export HINDSIGHT_API_URL=http://localhost:8888
export HINDSIGHT_API_KEY=your-api-key
```

### Named Profiles

When you need to switch between multiple Hindsight deployments (e.g. local,
staging, production) without constantly rewriting `~/.hindsight/config`, use
named profiles. Each profile is a TOML file at
`~/.hindsight/cli-profiles/<name>.toml` and is selected per-invocation with
`-p/--profile` (or by setting `$HINDSIGHT_PROFILE`).

<CodeSnippet code={cliSh} section="cli-profiles" language="bash" />

Profile files are written with `0600` permissions on Unix so the API key is
only readable by the owner.

**Configuration precedence** (highest first):

1. Environment variables (`HINDSIGHT_API_URL`, `HINDSIGHT_API_KEY`)
2. Named profile — explicit `-p <name>`, otherwise `$HINDSIGHT_PROFILE`
3. Shared config file (`~/.hindsight/config`, written by `hindsight configure`)
4. Default (`http://localhost:8888`)

`HINDSIGHT_API_URL` / `HINDSIGHT_API_KEY` always override profile values, which
makes it safe to use `-p` in scripts while letting CI inject credentials via
environment.

## Core Commands

### Retain (Store Memory)

Store a single memory:

<CodeSnippet code={cliSh} section="cli-retain" language="bash" />

### Retain Files

Bulk import from files:

<CodeSnippet code={cliSh} section="cli-retain-files" language="bash" />

### Recall (Search)

Search memories using semantic similarity:

<CodeSnippet code={cliSh} section="cli-recall" language="bash" />

### Reflect (Generate Response)

Generate a response using memories and bank disposition:

<CodeSnippet code={cliSh} section="cli-reflect" language="bash" />

### Memory History

View the observation history for a specific memory unit:

<CodeSnippet code={cliSh} section="cli-memory-history" language="bash" />

### Clear Observations

Remove all observations for a memory unit, keeping the core fact:

<CodeSnippet code={cliSh} section="cli-memory-clear-observations" language="bash" />

## Bank Management

### List Banks

<CodeSnippet code={cliSh} section="cli-bank-list" language="bash" />

### View Disposition

<CodeSnippet code={cliSh} section="cli-bank-disposition" language="bash" />

### Set Disposition

<CodeSnippet code={cliSh} section="cli-bank-set-disposition" language="bash" />

### View Statistics

<CodeSnippet code={cliSh} section="cli-bank-stats" language="bash" />

### Set Bank Name

<CodeSnippet code={cliSh} section="cli-bank-name" language="bash" />

### Set Mission

<CodeSnippet code={cliSh} section="cli-bank-mission" language="bash" />

### Clear Observations (Bank-wide)

Remove all observations across the entire bank:

<CodeSnippet code={cliSh} section="cli-bank-clear-observations" language="bash" />

### Recover Consolidation

Recover from a failed or stuck consolidation:

<CodeSnippet code={cliSh} section="cli-bank-consolidation-recover" language="bash" />

## Document Management

<CodeSnippet code={cliSh} section="cli-documents" language="bash" />

## Entity Management

<CodeSnippet code={cliSh} section="cli-entities" language="bash" />

## Operation Management

Track and manage async operations (retain-files, consolidation, etc.):

<CodeSnippet code={cliSh} section="cli-operations" language="bash" />

## Webhook Management

Configure event delivery hooks for bank activity:

<CodeSnippet code={cliSh} section="cli-webhooks" language="bash" />

## Knowledge Base

Manage a bank's knowledge pages — living documents organized in a folder tree. See [Knowledge Pages](../developer/api/knowledge-pages) for what they are and how they refresh.

<CodeSnippet code={cliSh} section="cli-kb-tree" language="bash" />

<CodeSnippet code={cliSh} section="cli-kb-create-folder" language="bash" />

<CodeSnippet code={cliSh} section="cli-kb-create-subfolder" language="bash" />

<CodeSnippet code={cliSh} section="cli-kb-create-page" language="bash" />

<CodeSnippet code={cliSh} section="cli-kb-manage" language="bash" />

:::tip
`hindsight fs mount --bank <bank_id>` mirrors the same knowledge base onto disk as
real markdown files, kept current by a background refresh loop — handy when you'd
rather use `grep`, `rg`, or your editor than the commands above.
:::

## Audit Logs

Inspect the audit trail for a bank:

<CodeSnippet code={cliSh} section="cli-audit" language="bash" />

The list shows each entry without its request and response, which can be large. To see them, pass an entry's id from the list to `hindsight audit get <bank_id> <log_id>`.

## Output Formats

<CodeSnippet code={cliSh} section="cli-output" language="bash" />

## Global Options

| Flag | Description |
|------|-------------|
| `-v, --verbose` | Show detailed output including request/response |
| `-o, --output <format>` | Output format: pretty, json, yaml |
| `-p, --profile <name>` | Use a named profile (see [Named Profiles](#named-profiles)) |
| `--help` | Show help |
| `--version` | Show version |

## Control Plane UI

Launch the web-based Control Plane UI directly from the CLI:

```bash
hindsight ui
```

This runs the Control Plane locally on port 9999 using the API URL from your configuration. The UI provides:

- **Memory bank management** — Browse and manage all your banks
- **Entity explorer** — Visualize the knowledge graph
- **Query testing** — Interactive recall and reflect testing
- **Operation history** — View ingestion and processing logs

:::tip
The UI command requires Node.js to be installed. It automatically downloads and runs the `@vectorize-io/hindsight-control-plane` package via npx.
:::

## Interactive Explorer

Launch the TUI explorer for visual navigation of your memory banks:

```bash
hindsight explore
```

The explorer provides an interactive terminal interface to:

- **Browse memory banks** — View all banks and their statistics
- **Search memories** — Run recall queries with real-time results
- **Inspect memory details** — Open a memory to view its text, metadata, entities, and full JSON fields
- **Inspect entities** — Explore the knowledge graph and entity relationships
- **View facts** — Browse world facts, experiences, and observations
- **Navigate documents** — See source documents and their extracted memories

### Keyboard Shortcuts

| Key | Action |
|-----|--------|
| `↑/↓` | Navigate items |
| `Enter` | Select item / view details |
| `Tab` | Switch panels |
| `/` | Search |
| `q` | Quit |

{/* Screenshot placeholder: explore command TUI */}

## Example Workflow

<CodeSnippet code={cliSh} section="cli-workflow" language="bash" />

---

### Hindsight Docs Docs Sdks Embed (`hindsight-docs/docs/sdks/embed.md`)

---
sidebar_position: 5
---

# Daemon CLI (hindsight-embed)

Zero-configuration local memory system with automatic daemon management. Perfect for development, prototyping, and single-user applications.

## Overview

`hindsight-embed` is a zero-configuration SDK that wraps the Hindsight API and PostgreSQL database into a single auto-managed local daemon. It's designed for development, prototyping, and single-user applications where you want memory capabilities without infrastructure overhead.

**How it works:**

1. **First command triggers startup**: When you run any `hindsight-embed` command, it checks if a local daemon is running
2. **Auto-daemon management**: If no daemon exists, it automatically spawns `hindsight-api --daemon` in the background
3. **Embedded database**: The daemon uses `pg0` (embedded PostgreSQL) — no separate database installation required
4. **Command forwarding**: Your command is forwarded to the local daemon via HTTP (localhost:8888)
5. **Auto-shutdown**: After 5 minutes of inactivity (configurable), the daemon gracefully shuts down to free resources

**Key features:**

- **Zero setup** — One `configure` command and you're ready
- **Automatic lifecycle** — Daemon starts on-demand and is shared by every client
- **Isolated storage** — Each bank gets its own embedded PostgreSQL database
- **Local-only** — Binds to `127.0.0.1:8888`, not accessible from network
- **Production-grade engine** — Uses the same memory engine as the full API service

Think of it as SQLite for long-term memory — all the power of Hindsight without managing servers.

## Installation

Install via `uvx` (recommended - always latest version):

```bash
# Run directly without installation
uvx hindsight-embed@latest configure

# Or use pipx for persistent installation
pipx install hindsight-embed
```

## Quick Start

### 1. Configure

```bash
# Interactive configuration
hindsight-embed configure

# Or non-interactive via environment variables
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
hindsight-embed configure
```

Configuration is saved to `~/.hindsight/embed`:

```bash
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx

# Daemon settings (macOS: force CPU to avoid MPS/XPC issues)
HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1
HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1
```

### 2. Use Memory Operations

```bash
# Store a memory
hindsight-embed memory retain default "User prefers dark mode"

# Query memories
hindsight-embed memory recall default "user preferences"

# Reasoning with memory
hindsight-embed memory reflect default "What color scheme should I use?"
```

The daemon starts automatically on first use!

### 3. Open the Control Center (optional)

Use the local control center when you want a browser-based configuration wizard and daemon supervisor:

```bash
# Launch the control center and open the browser
hindsight-embed control start

# Or use the browser wizard instead of terminal prompts during setup
hindsight-embed configure --ui
```

The control center listens on localhost only (`http://localhost:7878` by default) and prints a tokenized URL. It stores the local access token at `~/.hindsight/control.token` and writes logs to `~/.hindsight/control.log`.

```bash
# Pick a different control-center port for this launch
hindsight-embed control start --port 7879

# Start without opening a browser automatically
hindsight-embed control start --no-open

# Check, inspect, or stop the control center
hindsight-embed control status
hindsight-embed control logs -f
hindsight-embed control stop
```

The control center runs as a separate process from the memory daemon. Stopping or restarting the control center does not stop a running daemon.

## Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_LLM_API_KEY` | **Required**. API key for LLM provider | - |
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama` | `openai` |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` |
| `HINDSIGHT_EMBED_DAEMON_LOG_MAX_BYTES` | Rotate the daemon log at startup when it reaches this size; `0` disables rotation | `10485760` (10 MiB) |
| `HINDSIGHT_EMBED_DAEMON_LOG_BACKUP_COUNT` | Retained backups; `0` truncates a full log at startup | `3` |
| `HINDSIGHT_EMBED_CONTROL_PORT` | Default port for `hindsight-embed control start` | `7878` |

The size is checked only when a daemon starts, so a single uninterrupted run is never truncated and can
grow past `MAX_BYTES` — and at the next start that whole file is kept as the first backup. Retained size
is therefore around `MAX_BYTES × (BACKUP_COUNT + 1)` (40 MiB by default) only for daemons that restart
regularly; a daemon left running for weeks keeps whatever it wrote. Restart it to keep the bound
meaningful.

**Provider Examples:**

```bash
# OpenAI
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o

# Groq (fast inference)
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=llama-3.3-70b-versatile

# 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
```

## Daemon Management

The daemon runs until you stop it — it never exits on its own, so a long retain, reflect or
consolidation is never cut short.

### Daemon Commands

```bash
# Check daemon status
hindsight-embed daemon status

# View daemon logs in real-time
hindsight-embed daemon logs -f

# Stop daemon manually
hindsight-embed daemon stop
```

### Reclaiming Disk Space

When the daemon is launched with `uvx` (the default for the npm package and the editor plugins), every Hindsight version it has run keeps its own cached Python environment — around 1.5 GB each — and these are not removed automatically. If disk space gets tight, stop the daemon before clearing the cache: unused entries can only be reclaimed while no daemon is running.

```bash
hindsight-embed daemon stop
uv cache prune
hindsight-embed daemon status   # starts fresh on the current version
```

### Control Center Commands

```bash
# Start or reuse the local browser control center
hindsight-embed control start

# Check whether it is running
hindsight-embed control status

# View control-center logs
hindsight-embed control logs -f

# Stop the control-center process
hindsight-embed control stop
```

## Commands

All memory operations follow the same interface as the CLI:

### Retain (Store Memory)

```bash
hindsight-embed memory retain <bank_id> "content"

# With context
hindsight-embed memory retain <bank_id> "content" --context "source information"

# Background processing
hindsight-embed memory retain <bank_id> "content" --async
```

### Recall (Search)

```bash
hindsight-embed memory recall <bank_id> "query"

# With budget control
hindsight-embed memory recall <bank_id> "query" --budget high

# Show trace
hindsight-embed memory recall <bank_id> "query" --trace
```

### Reflect (Generate Response)

```bash
hindsight-embed memory reflect <bank_id> "prompt"

# With additional context
hindsight-embed memory reflect <bank_id> "prompt" --context "additional info"
```

### Bank Management

```bash
# List all banks
hindsight-embed bank list

# View bank stats
hindsight-embed bank stats <bank_id>

# Set bank name
hindsight-embed bank name <bank_id> "My Assistant"

# Set bank mission
hindsight-embed bank mission <bank_id> "I am a helpful AI assistant"
```

## Troubleshooting

### Daemon Won't Start

Check the daemon logs:

```bash
hindsight-embed daemon logs
# Or watch in real-time
hindsight-embed daemon logs -f
```

Common issues:
- **Missing API key**: Set `HINDSIGHT_API_LLM_API_KEY`
- **Port conflict**: Another service using port 8888
- **Permissions**: Check `~/.hindsight/` directory permissions

### Daemon Exits Immediately

The daemon never stops itself, so an exit is a crash or a failed startup. Check the log:

```bash
hindsight-embed daemon status
tail -n 100 ~/.hindsight/daemon.log
```

### Reset Configuration

```bash
# Remove config file and reconfigure
rm ~/.hindsight/embed
hindsight-embed configure
```

## When to Use

**Perfect for:**
- Development and prototyping
- Single-user applications
- Local-first tools
- Quick experiments with Hindsight

**Not suitable for:**
- Production multi-user deployments
- Network-accessible services
- High-availability requirements
- Multi-tenant applications

For production deployments, use the [API Service](/developer/services) with external PostgreSQL instead.

---

### Hindsight Docs Docs Sdks Go (`hindsight-docs/docs/sdks/go.md`)

---
sidebar_position: 3
---

# Go Client

Official Go client for the Hindsight API, generated from the OpenAPI 3.1 spec using [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator).

import CodeSnippet from '@site/src/components/CodeSnippet';
import quickstartGo from '!!raw-loader!@site/examples/api/quickstart.go';

## Installation

```bash
go get github.com/vectorize-io/hindsight/hindsight-clients/go
```

Requires Go 1.23+.

## Quick Start

<CodeSnippet code={quickstartGo} section="quickstart-full" language="go" />

## API Structure

The Go client provides access to all Hindsight API operations through structured namespaces:

- **`client.MemoryAPI`** - Retain, recall, reflect operations
- **`client.BanksAPI`** - Bank management
- **`client.DirectivesAPI`** - Directive management
- **`client.MentalModelsAPI`** - Mental model management
- **`client.DocumentsAPI`** - Document operations
- **`client.EntitiesAPI`** - Entity operations
- **`client.OperationsAPI`** - Async operation monitoring

## Working with Nullable Fields

The Go client uses `NullableString`, `NullableTime`, and similar types for optional fields:

<CodeSnippet code={quickstartGo} section="nullable-fields" language="go" />

## Error Handling

<CodeSnippet code={quickstartGo} section="error-handling" language="go" />

## More Examples

For detailed examples of all operations, see:
- [Python SDK documentation](./python.mdx) - API concepts are the same
- [Node.js SDK documentation](./nodejs.mdx) - API concepts are the same
- [OpenAPI specification](https://hindsight.dev/openapi.json) - Complete API reference

---

### Hindsight Docs Docs Sdks Hindsight All Npm (`hindsight-docs/docs/sdks/hindsight-all-npm.md`)

---
sidebar_position: 6
---

# Programmatic API (Node.js)

The `@vectorize-io/hindsight-all` npm package is the Node.js equivalent of the Python [`hindsight-all`](./hindsight-all.md) package. It lets your Node code spawn and supervise a local Hindsight daemon without deploying any server infrastructure — pair it with [`@vectorize-io/hindsight-client`](./nodejs.mdx) for memory operations.

The daemon runs as a **separate OS process** on `127.0.0.1` (not in your Node process). Your code talks to it over HTTP via `HindsightClient`.

This package **does not ship an HTTP client** — it only owns the server process. Once the daemon is running, talk to it with [`@vectorize-io/hindsight-client`](./nodejs.mdx) against `server.getBaseUrl()`. The two packages compose: one owns the process, the other owns the API surface.

## How it works

1. `server.start()` resolves the underlying `hindsight-embed` command (via `uvx` from PyPI, or `uv run --directory <path>` for a local checkout).
2. Runs `profile create <name> --merge --port <port> [--env KEY=VALUE ...]` with every entry from `options.env` forwarded as `--env`.
3. Runs `daemon --profile <name> start`.
4. Polls `http://host:port/health` until it returns `200` or the `readyTimeoutMs` budget is exhausted.
5. `server.stop()` runs `daemon --profile <name> stop`.

The server is intentionally transparent: new daemon env vars or CLI flags never require a wrapper release — pass them through `env`, `extraProfileCreateArgs`, or `extraDaemonStartArgs`.

## Requirements

- **Node.js ≥ 22** — uses global `fetch` and `AbortSignal.timeout`.
- **`uv` / `uvx`** on `PATH` — used to download and run the Hindsight daemon. Install via [docs.astral.sh/uv](https://docs.astral.sh/uv/).

## Install

```bash
npm install @vectorize-io/hindsight-all @vectorize-io/hindsight-client
```

## Example

```ts
import { HindsightServer, consoleLogger } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';

const server = new HindsightServer({
  profile: 'my-app',
  port: 9077,
  env: {
    HINDSIGHT_API_LLM_PROVIDER: 'anthropic',
    HINDSIGHT_API_LLM_API_KEY: process.env.ANTHROPIC_API_KEY,
    HINDSIGHT_API_LLM_MODEL: 'claude-sonnet-4-20250514',
  },
  logger: consoleLogger,
});

await server.start();

const client = new HindsightClient({ baseUrl: server.getBaseUrl() });
await client.retain('user-123', 'User prefers dark mode.');
const recall = await client.recall('user-123', 'what are the user preferences?');

await server.stop();
```

For a remote Hindsight API, skip the server entirely and point `HindsightClient` directly at the remote URL.

## `HindsightServerOptions`

| Option | Type | Default | Description |
|---|---|---|---|
| `profile` | `string` | `"default"` | Profile name passed to `--profile` on every sub-command. |
| `port` | `number` | `8888` | TCP port the daemon listens on. |
| `host` | `string` | `"127.0.0.1"` | Hostname the daemon binds to (used for health checks). |
| `embedVersion` | `string` | `"latest"` | Version of the underlying `hindsight-embed` package to run via `uvx`. |
| `embedPackagePath` | `string` | — | Local checkout path — takes precedence over `embedVersion`. Uses `uv run --directory` instead of `uvx`. |
| `env` | `Record<string, string \| undefined>` | `{}` | Environment variables passed to the daemon process **and** written into the profile config via `--env KEY=VALUE`. The preferred way to surface any `HINDSIGHT_API_*` / `HINDSIGHT_EMBED_*` setting. |
| `extraProfileCreateArgs` | `string[]` | `[]` | Extra args appended verbatim to `profile create`. |
| `extraDaemonStartArgs` | `string[]` | `[]` | Extra args appended verbatim to `daemon start`. |
| `platformCpuWorkaround` | `boolean` | `true` on macOS | Auto-set `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1` and `HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1` to avoid Metal/MPS crashes. Caller-supplied `env` values win over the auto-applied ones. |
| `readyTimeoutMs` | `number` | `30000` | Max time to wait for `/health` to return 200. |
| `readyPollIntervalMs` | `number` | `1000` | Polling interval while waiting for `/health`. |
| `logger` | `Logger` | silent | Pluggable logger (`debug`/`info`/`warn`/`error`). `consoleLogger` and `silentLogger` helpers are exported. |

## Server methods

| Method | Returns | Description |
|---|---|---|
| `start()` | `Promise<void>` | Configure profile, spawn the daemon, wait for `/health`. Idempotent — safe to re-run. |
| `stop()` | `Promise<void>` | Stop the daemon. Never throws; logs and resolves even on failure. |
| `checkHealth()` | `Promise<boolean>` | One-shot `/health` probe with a 2 s timeout. |
| `getBaseUrl()` | `string` | `http://host:port` — pass this straight to `HindsightClient`. |
| `getProfile()` | `string` | The profile name this server operates on. |

For memory operations (retain, recall, reflect, bank management) use [`@vectorize-io/hindsight-client`](./nodejs.mdx).

---

### Hindsight Docs Docs Sdks Hindsight All (`hindsight-docs/docs/sdks/hindsight-all.md`)

---
sidebar_position: 2
---

# Programmatic API (Python)

The `hindsight-all` Python package lets your code spawn and manage a local Hindsight daemon without deploying any server infrastructure. It bundles the Hindsight API server, embedded PostgreSQL, and the Python client into one install — `pip install hindsight-all` and you can start a fully-functional Hindsight instance from a few lines of Python.

The daemon runs as a **separate OS process** on `127.0.0.1` (not in your Python process memory). Your code talks to it over HTTP via the bundled `HindsightClient`.

If you already have a Hindsight server running and just need a client, use [Python Client (hindsight-client)](./python.mdx) instead.

## How it works

`hindsight-all` exposes two primary APIs:

- **`HindsightServer`** — explicit lifecycle. Use it as a context manager when you want deterministic startup/shutdown (e.g. in tests).
- **`HindsightEmbedded`** — auto-managed. Starts a daemon on first use and reuses it across calls. Easiest for application code that doesn't want to think about lifecycle.

Both end up talking to the same underlying daemon via the same `HindsightClient` HTTP interface — the difference is only how the server process is managed.

## Installation

```bash
pip install hindsight-all
```

The `hindsight-all` wheel bundles `hindsight-api-slim`, `hindsight-client`, and `hindsight-embed` as dependencies, so one `pip install` gets you everything.

On Intel (x86_64) Macs, install `hindsight-all-slim` instead — the full bundle's local ML models have no Intel-Mac wheels. See [Supported Platforms](../developer/installation#supported-platforms).

## `HindsightServer` — explicit lifecycle

Use `HindsightServer` as a context manager when you want the server to start immediately, run for the duration of a block, and shut down cleanly afterwards. Ideal for tests and short-lived scripts.

```python
import os
from hindsight import HindsightServer, HindsightClient

with HindsightServer(
    llm_provider="openai",
    llm_model="gpt-4o-mini",
    llm_api_key=os.environ["OPENAI_API_KEY"],
) as server:
    client = HindsightClient(base_url=server.url)

    client.retain(bank_id="my-bank", content="Alice works at Google")
    results = client.recall(bank_id="my-bank", query="What does Alice do?")
    for r in results:
        print(r.text)

    answer = client.reflect(bank_id="my-bank", query="Tell me about Alice")
    print(answer.text)
# Server is stopped here
```

## `HindsightEmbedded` — auto-managed

`HindsightEmbedded` is the simplest way to use Hindsight in Python. It automatically manages a background daemon for you — starts on first use and stays alive across calls.

```python
from hindsight import HindsightEmbedded
import os

# Server starts automatically on first call
client = HindsightEmbedded(
    profile="myapp",                        # Profile for data isolation
    llm_provider="openai",
    llm_model="gpt-4o-mini",
    llm_api_key=os.environ["OPENAI_API_KEY"],
)

# Use immediately - no manual server management needed
client.retain(bank_id="my-bank", content="Alice works at Google")
results = client.recall(bank_id="my-bank", query="What does Alice do?")

# Server continues running (stop it with `hindsight-embed daemon stop`)
# Or explicitly stop it:
client.close(stop_daemon=True)
```

### What's a Profile?

A profile is an isolated Hindsight environment. Each profile gets its own embedded PostgreSQL database (stored in `~/.pg0/instances/hindsight-embed-{profile}/`) and its own API server. Use different profiles to separate environments (dev/prod), applications, or users.

### When to use which

| Use case | Pick |
|---|---|
| Tests, short-lived scripts, deterministic startup/shutdown | `HindsightServer` (context manager) |
| Long-running application, auto-start on first use, don't want to manage lifecycle | `HindsightEmbedded` |
| Existing Hindsight server running elsewhere | [`hindsight-client`](./python.mdx) directly |

## API namespaces

Both `HindsightEmbedded` and `HindsightClient` expose organized API namespaces for bank management, mental models, directives, and memories:

```python
from hindsight import HindsightEmbedded
import os

embedded = HindsightEmbedded(
    profile="myapp",
    llm_provider="openai",
    llm_api_key=os.environ["OPENAI_API_KEY"],
)

# Core operations
embedded.retain(bank_id="test", content="Hello")
results = embedded.recall(bank_id="test", query="Hello")

# Bank management
embedded.banks.create(bank_id="test", name="Test Bank", mission="Help users")
embedded.banks.set_mission(bank_id="test", mission="Updated mission")
embedded.banks.delete(bank_id="test")

# Mental models
embedded.mental_models.create(
    bank_id="test",
    name="User Preferences",
    content="User prefers dark mode"
)
models = embedded.mental_models.list(bank_id="test")

# Directives
embedded.directives.create(
    bank_id="test",
    name="Response Style",
    content="Be concise and friendly"
)
directives = embedded.directives.list(bank_id="test")

# List memories
memories = embedded.memories.list(bank_id="test", type="world", limit=50)
```

API namespaces ensure the daemon is running before each call, so daemon crashes are handled gracefully:

```python
# ✅ GOOD - Uses API namespace (daemon restarts handled)
embedded.banks.create(bank_id="test", name="Test")

# ❌ BAD - Direct client access (daemon crashes NOT handled)
client = embedded.client
client.create_bank(bank_id="test", name="Test")  # Fails if daemon crashed
```

For the full reference of retain/recall/reflect methods and their options (which work the same regardless of how you obtain the client) see the [Python Client page](./python.mdx).

---

### Hindsight Docs Docs Sdks Nodejs (`hindsight-docs/docs/sdks/nodejs.mdx`)

---
sidebar_position: 2
---

import CodeSnippet from '@site/src/components/CodeSnippet';
import nodejsSdkMjs from '!!raw-loader!@site/examples/api/nodejs-sdk.mjs';

# TypeScript / JavaScript Client

Official TypeScript/JavaScript client for the Hindsight API. Supports **Node.js** and **Deno**.

## Installation

### Node.js

```bash
npm install @vectorize-io/hindsight-client
```

### Deno

No installation needed — import directly via the `npm:` specifier:

```typescript
import { HindsightClient } from "npm:@vectorize-io/hindsight-client";
```

## Quick Start

<CodeSnippet code={nodejsSdkMjs} section="quickstart" language="typescript" />

## Client Initialization

<CodeSnippet code={nodejsSdkMjs} section="client-init" language="typescript" />

## Core Operations

### Version and Feature Checks

<CodeSnippet code={nodejsSdkMjs} section="get-version" language="typescript" />

### Retain (Store Memory)

<CodeSnippet code={nodejsSdkMjs} section="retain" language="typescript" />

### Retain Batch

<CodeSnippet code={nodejsSdkMjs} section="retain-batch" language="typescript" />

### Recall (Search)

<CodeSnippet code={nodejsSdkMjs} section="recall" language="typescript" />

### Reflect (Generate Response)

<CodeSnippet code={nodejsSdkMjs} section="reflect" language="typescript" />

## Bank Management

### Create Bank

<CodeSnippet code={nodejsSdkMjs} section="create-bank" language="typescript" />

### List Memories

<CodeSnippet code={nodejsSdkMjs} section="list-memories" language="typescript" />

## Document Management

### Get Document

<CodeSnippet code={nodejsSdkMjs} section="get-document" language="typescript" />

### List Documents

<CodeSnippet code={nodejsSdkMjs} section="list-documents" language="typescript" />

### Update Document

<CodeSnippet code={nodejsSdkMjs} section="update-document" language="typescript" />

### Delete Document

<CodeSnippet code={nodejsSdkMjs} section="delete-document" language="typescript" />

---

### Hindsight Docs Docs Sdks Python (`hindsight-docs/docs/sdks/python.mdx`)

---
sidebar_position: 1
---

import CodeSnippet from '@site/src/components/CodeSnippet';
import pythonSdkPy from '!!raw-loader!@site/examples/api/python-sdk.py';

# Python Client

Official HTTP client for the Hindsight API. Use this when you have a Hindsight server already running — locally, in Docker, or as a managed service — and you want a typed Python client to talk to it.

If you want to **embed and run a Hindsight server in your Python process** (no external server required), see [Embedded Python (hindsight-all)](./hindsight-all.md) instead.

## Installation

```bash
pip install hindsight-client
```

## Quick Start

<CodeSnippet code={pythonSdkPy} section="quickstart" language="python" />

## Client Initialization

<CodeSnippet code={pythonSdkPy} section="client-init" language="python" />

For operations the helper methods don't cover, the client also exposes the full generated API as async namespaces (`client.banks`, `client.memory`, `client.documents`, `client.mental_models`, `client.directives`, ...). Their methods must be awaited.

## Core Operations

### Version and Feature Checks

<CodeSnippet code={pythonSdkPy} section="get-version" language="python" />

The async client method is available as `await client.aget_version()`.

### Retain (Store Memory)

<CodeSnippet code={pythonSdkPy} section="retain" language="python" />

### Retain Batch

<CodeSnippet code={pythonSdkPy} section="retain-batch" language="python" />

### Recall (Search)

<CodeSnippet code={pythonSdkPy} section="recall" language="python" />

### Recall with Chunks

<CodeSnippet code={pythonSdkPy} section="recall-chunks" language="python" />

### Reflect (Generate Response)

<CodeSnippet code={pythonSdkPy} section="reflect" language="python" />

## Bank Management

### Create Bank

<CodeSnippet code={pythonSdkPy} section="create-bank" language="python" />

### List Memories

<CodeSnippet code={pythonSdkPy} section="list-memories" language="python" />

## Async Support

All methods have async versions prefixed with `a`:

<CodeSnippet code={pythonSdkPy} section="async" language="python" />

## Context Manager

<CodeSnippet code={pythonSdkPy} section="context-manager" language="python" />

---

### Hindsight Docs Docs Developer Api Bank Templates (`hindsight-docs/docs/developer/api/bank-templates.mdx`)

---
sidebar_position: 9
---

# Bank Templates

Declarative JSON manifests for creating pre-configured memory banks with a single API call.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import templatesPy from '!!raw-loader!@site/examples/api/bank-templates.py';
import templatesMjs from '!!raw-loader!@site/examples/api/bank-templates.mjs';
import templatesSh from '!!raw-loader!@site/examples/api/bank-templates.sh';
import templatesGo from '!!raw-loader!@site/examples/api/bank-templates.go';

## Overview

A bank template is a JSON manifest that describes a bank's full setup: configuration overrides, mental models, directives, and more. Instead of making multiple API calls to configure a bank, you submit one manifest and the API provisions everything.

Templates are useful for:
- **Replication** — stamp out identically-configured banks for multiple users or agents
- **Onboarding** — new users start with a known-good configuration instead of configuring from scratch
- **Sharing** — distribute recommended setups as portable JSON files
- **Framework integrations** — ship a recommended template alongside your integration

Browse the [Bank Templates Hub](/templates) for ready-to-use templates.

## Manifest Schema

```json
{
  "version": "1",
  "bank": {
    "reflect_mission": "...",
    "retain_mission": "...",
    "retain_extraction_mode": "concise | verbose | custom | verbatim | chunks",
    "retain_custom_instructions": "...",
    "retain_chunk_size": 2048,
    "retain_structured_chunk_size": 8192,
    "disposition_skepticism": 3,
    "disposition_literalism": 3,
    "disposition_empathy": 3,
    "enable_observations": true,
    "observations_mission": "...",
    "entity_labels": [{ "key": "sentiment", "type": "value", "values": [{ "value": "positive" }, { "value": "negative" }] }],
    "entities_allow_free_form": true
  },
  "mental_models": [
    {
      "id": "unique-lowercase-id",
      "name": "Human-Readable Name",
      "source_query": "The query that generates this mental model's content",
      "tags": ["optional", "tags"],
      "max_tokens": 2048,
      "trigger": {
        "refresh_after_consolidation": false,
        "fact_types": ["world", "experience", "observation"],
        "exclude_mental_models": false,
        "exclude_mental_model_ids": []
      }
    }
  ],
  "directives": [
    {
      "name": "directive-name",
      "content": "The directive instruction text",
      "priority": 0,
      "is_active": true,
      "tags": ["optional", "tags"]
    }
  ]
}
```

### Fields

| Field | Required | Description |
|-------|----------|-------------|
| `version` | Yes | Schema version. Currently `"1"`. |
| `bank` | No | Bank configuration overrides. Omit to leave config unchanged. |
| `mental_models` | No | Mental models to create or update. Omit to leave unchanged. |
| `directives` | No | Directives to create or update. Omit to leave unchanged. |

All of `bank`, `mental_models`, and `directives` are optional. Omit any section to leave that part of the bank unchanged.

### Bank Config Fields

Every per-bank configuration setting can be carried in a template, so an exported
bank reproduces its full configuration when imported elsewhere. All fields in
`bank` are optional — only the fields you include are set as per-bank overrides,
and everything else inherits from the server/tenant defaults.

The complete, always-current list is the [template JSON
Schema](/bank-template-schema.json); each field means the same thing it does in
[Configuration](../configuration.mdx). The most commonly used ones:

| Field | Type | Description |
|-------|------|-------------|
| `reflect_mission` | string | Mission/context for reflect operations |
| `retain_mission` | string | Steers what gets extracted during retain |
| `retain_extraction_mode` | string | `concise`, `verbose`, `custom`, `verbatim`, or `chunks` |
| `retain_custom_instructions` | string | Custom extraction prompt (requires `mode=custom`) |
| `retain_chunk_size` | integer | Target max characters per content chunk |
| `retain_structured_chunk_size` | integer | Max characters for a single JSONL line or conversation turn to keep whole; defaults to `retain_chunk_size` when unset |
| `disposition_skepticism` | integer (1-5) | How skeptical the disposition is |
| `disposition_literalism` | integer (1-5) | How literal the disposition is |
| `disposition_empathy` | integer (1-5) | How empathetic the disposition is |
| `enable_observations` | boolean | Toggle observation consolidation |
| `observations_mission` | string | Controls what gets synthesised into observations |
| `entity_labels` | object[] | Controlled vocabulary as label groups — see [Memory Banks → entity_labels](./memory-banks#entity-labels) |
| `entities_allow_free_form` | boolean | Allow entities outside the label vocabulary |

### Mental Model Fields

| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique ID (lowercase alphanumeric with hyphens). Used to match on re-import. |
| `name` | Yes | Human-readable name |
| `source_query` | Yes | The query that generates this model's content via reflect |
| `tags` | No | Tags for scoped visibility. Default: `[]` |
| `max_tokens` | No | Max tokens for generated content (256-8192). Default: `2048` |
| `trigger` | No | Trigger settings for auto-refresh |

### Directive Fields

| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Directive name. Used as the match key on re-import. |
| `content` | Yes | The directive instruction text. |
| `priority` | No | Priority value (higher = more important). Default: `0` |
| `is_active` | No | Whether the directive is active. Default: `true` |
| `tags` | No | Tags for categorization. Default: `[]` |

## Import

Import a manifest into a bank. If the bank doesn't exist, it's created automatically.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={templatesPy} section="import-template" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={templatesMjs} section="import-template" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={templatesSh} section="import-template" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={templatesGo} section="import-template" language="go" />
</TabItem>
</Tabs>

### Behavior

- **Config**: all `bank` fields are applied as per-bank config overrides
- **Mental models**: matched by `id` — new ones are created, existing ones are updated only when their definition differs from the manifest
- **Directives**: matched by `name` — new ones are created, existing ones are updated only when they differ from the manifest
- **Async**: mental model content is generated asynchronously. The response includes `operation_ids` to track progress.
- **Idempotent**: re-importing an unchanged manifest updates nothing and queues no refresh, so applying the same template on every sync costs no LLM calls. Only created or changed mental models are regenerated.

### Dry Run

Validate a manifest without applying changes:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={templatesPy} section="import-dry-run" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={templatesMjs} section="import-dry-run" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={templatesSh} section="import-dry-run" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={templatesGo} section="import-dry-run" language="go" />
</TabItem>
</Tabs>

Returns what *would* happen (which config would be applied, which mental models would be created) without making any changes. Returns HTTP 400 with a detailed error message if the manifest is invalid.

## Export

Export a bank's current config overrides, mental models, and directives as a manifest:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={templatesPy} section="export-template" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={templatesMjs} section="export-template" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={templatesSh} section="export-template" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={templatesGo} section="export-template" language="go" />
</TabItem>
</Tabs>

The exported manifest only includes config fields that were explicitly set as per-bank overrides — not the fully resolved config (which includes server/tenant defaults). This means the exported manifest is portable: importing it into a new bank only overrides the fields that were intentionally customized.

### Round-trip

Export from one bank and import into another to replicate the setup:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={templatesPy} section="export-reimport" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={templatesMjs} section="export-reimport" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={templatesSh} section="export-reimport" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={templatesGo} section="export-reimport" language="go" />
</TabItem>
</Tabs>

## JSON Schema

The manifest format is defined by a JSON Schema. Fetch the live schema from your server:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={templatesPy} section="get-schema" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={templatesMjs} section="get-schema" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={templatesSh} section="get-schema" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={templatesGo} section="get-schema" language="go" />
</TabItem>
</Tabs>

The static schema is also available at [bank-template-schema.json](/bank-template-schema.json).

## Control Plane

The control plane bank creation dialog includes an optional "Import from template" toggle. Enable it to paste a manifest JSON and pre-configure the bank on creation.

You can also export any bank's template from the bank Settings page via **Actions → Export Template**, which copies the manifest JSON to your clipboard.

## Versioning

The `version` field enables forward-compatible schema evolution. The current version is `"1"`.

When future versions are released:
- Older manifests are automatically upgraded to the current schema on import
- Export always produces the latest version
- The API rejects manifests with a version newer than what the server supports (with a clear error message suggesting an upgrade)

This means old templates keep working indefinitely — no need to manually update them.

---

### Hindsight Docs Docs Developer Api Documents (`hindsight-docs/docs/developer/api/documents.mdx`)

---
sidebar_position: 8
---

# Documents

Track and manage document sources in your memory bank. Documents provide traceability — knowing where memories came from.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import documentsPy from '!!raw-loader!@site/examples/api/documents.py';
import documentsMjs from '!!raw-loader!@site/examples/api/documents.mjs';
import documentsSh from '!!raw-loader!@site/examples/api/documents.sh';
import documentsGo from '!!raw-loader!@site/examples/api/documents.go';

:::tip 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

:::tip 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:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-retain" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-retain" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-retain" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-retain" language="go" />
</TabItem>
</Tabs>

## Update Documents

Re-retaining with the same document_id **replaces** the old content:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-update" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-update" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-update" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-update" language="go" />
</TabItem>
</Tabs>

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

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-get" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-get" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-get" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-get" language="go" />
</TabItem>
</Tabs>

## Update Document

Update mutable fields on an existing document without re-processing the content. Currently supports updating `tags`.

The `tags` array **replaces** the document's tags — it is not merged into them. Send the complete set you want the document to end up with: any tag you leave out is dropped, and an empty array clears them all. To remove a single tag, read the document's current tags, drop the one you want gone, and send the rest. Omitting the field entirely is not an update at all and is rejected with a `422`.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-update-tags" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-update-tags" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-update-tags" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-update-tags" language="go" />
</TabItem>
</Tabs>

:::info Observations are re-consolidated
When tags change, any consolidated observations derived from the document's memories are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.

This is required for correctness rather than incidental: consolidation scopes a memory by its tag set, so an observation built under the old tags is no longer valid, and deleting it would strand every other memory that observation was consolidated from unless those are requeued too. The size of that requeue is the number of memories co-sourced with this document's — on a densely co-sourced bank it can be many times the document's own memory count.

Tags are compared as a **set** against the document's current tags, and an update that leaves the set unchanged — including one that only reorders the array — performs no retag and queues no re-consolidation. A repeatable tag-normalisation sweep therefore only pays the re-consolidation cost on the run that actually changes something.
:::

## Delete Document

Remove a document and all its associated memories:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-delete" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-delete" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-delete" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-delete" language="go" />
</TabItem>
</Tabs>

:::warning
Deleting a document permanently removes all memories extracted from it. This action cannot be undone.
:::

## List Documents

List documents in a bank with optional filtering by ID, tags, and time.

`start_date=` and `end_date=` restrict the results to a half-open range `[start, end)` on the timestamp named by `time_field=` — either `created_at` (when the document first arrived) or `updated_at` (its last write, and the default ordering). `time_field` also becomes the sort order, newest first.

Because filtering and ordering follow the same column, `total` counts only the documents inside the window rather than the whole bank. Omit all three parameters to keep the default listing.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-list" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-list" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-list" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-list" language="go" />
</TabItem>
</Tabs>

### Filtering Options

| Parameter | Description |
|---|---|
| `q` | Case-insensitive substring match on document ID or document text. `report` matches `report-2024`, `annual-report`, or any document whose text contains `report`. Not indexed: meant for browsing and inspecting a bank, not for high-volume or latency-sensitive lookups (use recall for those). |
| `tags` | Filter by document tags. Accepts multiple values. |
| `tags_match` | How to match tags (default: `any_strict`). See below. |
| `limit` / `offset` | Pagination. Default limit is 100. |

When `q` is set, each returned row carries `matched_fields`: `["id"]`, `["original_text"]`, or both, so a caller can tell why a document matched.

A `q` search counts at most 1,000 matches. Past that, `total` is a lower bound, `total_is_estimate` is `true`, and pages stop at the cap. Narrow the search, or add a tag or time filter, to reach the rest. Some storage backends match `q` approximately — `matched_fields` always says what matched.

**`tags_match` modes:**

| Mode | Behaviour |
|---|---|
| `any_strict` *(default)* | Document must have **at least one** of the specified tags. Untagged docs excluded. |
| `any` | Same as `any_strict` but also includes untagged documents. |
| `all_strict` | Document must have **all** specified tags. Untagged docs excluded. |
| `all` | Same as `all_strict` but also includes untagged documents. |

## Document History

Every change to a document is recorded with what it did: each retain, append and reprocess (facts added and removed), each fact edit, invalidation, restore and delete, each per-fact observation clear, each tag change, and each consolidation run (observations created, updated and removed, filed under every document their source facts came from).

Observations are rebuilt rather than versioned, so when a change deletes one, this history is the only place its id is still recorded. Each event stores ids and counts only, never text, and each id list is capped at 100. The control plane shows this as the **History** tab of a document.

The history is rebuilt from the bank's [operations](./operations), which carry these events, so two limits apply:

- It reaches back only as far as operations are kept (`HINDSIGHT_API_OPERATION_RETENTION_DAYS`, unlimited by default).
- A change that ran without an operation is not in it: a synchronous retain (`async: false`), or a fact edit, delete or tag change while auto-consolidation is off. A fact edit, delete or tag change is recorded on the consolidation operation it queues, next to the rebuild it causes. Clearing every observation in the bank is not listed.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-history" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-history" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={documentsSh} section="document-history" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-history" language="go" />
</TabItem>
</Tabs>

```json
{
  "document_id": "call-2026-10-01",
  "total": 2,
  "limit": 50,
  "offset": 0,
  "items": [
    {
      "document_id": "call-2026-10-01",
      "kind": "consolidation",
      "created_at": "2026-10-19T10:02:11Z",
      "operation_id": "ce29121a-…",
      "fact_id": null,
      "facts_added": 0,
      "facts_removed": 0,
      "observations_created": { "count": 0, "ids": [] },
      "observations_updated": { "count": 2, "ids": ["9545…", "c51c…"] },
      "observations_removed": { "count": 0, "ids": [] }
    },
    {
      "document_id": "call-2026-10-01",
      "kind": "fact_edited",
      "created_at": "2026-10-19T10:01:50Z",
      "operation_id": "ce29121a-…",
      "fact_id": "41ab…",
      "facts_added": 0,
      "facts_removed": 0,
      "observations_created": { "count": 0, "ids": [] },
      "observations_updated": { "count": 0, "ids": [] },
      "observations_removed": { "count": 1, "ids": ["2098…"] }
    }
  ]
}
```

## 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,
  "nodes_by_fact_type": {
    "world": 5,
    "experience": 4,
    "observation": 3
  },
  "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

---

### Hindsight Docs Docs Developer Api Knowledge Pages (`hindsight-docs/docs/developer/api/knowledge-pages.mdx`)

---
sidebar_position: 5
---

# Knowledge Pages

Living markdown documents, organized in a folder tree, that rewrite themselves as the bank learns.

A page is backed by a [mental model](./mental-models) but is configured as a document: it is built from the bank's [observations](../observations) only, it never reads other pages, and it refreshes incrementally after each consolidation. See [Knowledge Pages](../knowledge-pages) for the concepts behind the API.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import knowledgePagesPy from '!!raw-loader!@site/examples/api/knowledge-pages.py';
import knowledgePagesMjs from '!!raw-loader!@site/examples/api/knowledge-pages.mjs';
import knowledgePagesSh from '!!raw-loader!@site/examples/api/knowledge-pages.sh';
import knowledgePagesGo from '!!raw-loader!@site/examples/api/knowledge-pages.go';

All endpoints below are relative to a bank:

```
/v1/default/banks/{bank_id}/knowledge-base
```

---

## Get the Tree

Returns the whole knowledge base as a nested tree of folders and pages. Page bodies are **not** included — fetch a page to read its content.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="get-tree" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="get-tree" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="get-tree" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="get-tree" language="go" />
</TabItem>
</Tabs>

```json
{
  "roots": [
    {
      "id": "kf-9f2c...",
      "kind": "folder",
      "name": "Operations",
      "parent_id": null,
      "mental_model_id": null,
      "managed": false,
      "description": null,
      "tags": [],
      "timestamp": "2026-08-01T11:04:02+00:00",
      "is_stale": null,
      "children": [
        {
          "id": "kp-2e85...",
          "kind": "page",
          "name": "Deploying the API",
          "parent_id": "kf-9f2c...",
          "mental_model_id": "mm-77ab...",
          "managed": false,
          "description": "How is the API deployed?",
          "tags": ["ops"],
          "timestamp": "2026-08-03T09:12:44+00:00",
          "is_stale": true,
          "children": []
        }
      ]
    }
  ]
}
```

| Field | Description |
|---|---|
| `kind` | `folder` or `page` |
| `mental_model_id` | The backing mental model (pages only) |
| `description` | The page's source query — the question that rebuilds it |
| `timestamp` | Last refresh for a page, last update for a folder |
| `is_stale` | Pages only: `true` when a memory in *this page's* scope has been written since the page last read the memories (see below) |
| `last_refresh_failed_at` | Pages only: when this page's last refresh failed, or `null` when it succeeded. While it is set the page does not rebuild itself on its trigger — an explicit refresh still runs, and a successful one clears it |
| `managed` | `true` when the node is flagged as system-owned rather than hand-authored |

### How `is_stale` is decided

Each page is answered against its own scope — its tags and its `fact_types` — using the same [staleness check](./mental-models#staleness-gating) that decides whether a scheduled refresh does any work. A flagged page is one a refresh would actually rewrite; an unflagged one is a page a refresh would leave alone. Activity elsewhere in the bank does not flag a page whose own scope is quiet.

The whole tree is answered in one query, so the flag costs the same whether the bank has three pages or three hundred, and [`GET /mental-models/{id}`](./mental-models) returns the identical value for the page's backing model.

One thing it does not see: **deletions**. The check asks what has been *written* since the page last read the memories, and deleting an in-scope memory leaves no write behind — a page that cites a deleted fact keeps reporting itself up to date.

---

## Create a Page

Creating a page stores it with placeholder content and schedules the first build in the background. Poll the returned `operation_id` via the [operations API](./operations) to know when the content is ready.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="create-page" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="create-page" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="create-page" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="create-page" language="go" />
</TabItem>
</Tabs>

```json
{
  "page_id": "kp-2e85...",
  "mental_model_id": "mm-77ab...",
  "operation_id": "op-1d0f..."
}
```

### Parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Page name. Must be unique within its folder, case-insensitively — a duplicate returns `409`. (Enforced on PostgreSQL only.) |
| `source_query` | string | Yes | The question the page answers, re-asked on every refresh. |
| `parent_id` | string | No | Folder to create the page in. `null` (or omitted) creates it at the root. |
| `tags` | list | No | Tags that scope which memories the page is built from — see [Tags Are a Filter](#tags-are-a-filter). A `type:<x>` tag also sets the page's rendered type, and still counts as part of the filter. |
| `max_tokens` | int | No | Content budget. Defaults to `4096` (a plain mental model defaults to `2048`). |
| `trigger` | object | No | Refresh configuration — see below. |

### Tags Are a Filter

`tags` are not labels on the page. They are the scope the page is built from, and a tagged page matches memories with **`all_strict`** by default: a memory must carry **every** one of the page's tags, and untagged memories are **excluded entirely**.

So a page created like this:

```json
{
  "name": "Homelab Infrastructure",
  "source_query": "NAS, ThinkPad, docker containers, jellyfin",
  "tags": ["type:runbook", "homelab", "infrastructure"]
}
```

is built only from memories tagged `type:runbook` **and** `homelab` **and** `infrastructure`. If your memories were retained without those exact tags — which is the usual case when the tags are invented at page-creation time to describe the topic — the page matches nothing and generates as *"I don't have information about this."* A direct [recall](./recall) for the same query still returns everything, because recall was not given the same filter.

The `type:<x>` tag makes this easy to trip over: it is documented as setting the page's rendered type, but it narrows retrieval like any other tag.

Three ways to get this right:

| You want | Do this |
|---|---|
| The page built from the whole bank | Omit `tags` (or pass `[]`) |
| The tags to scope the page, but untagged memories still included | Keep `tags`, add `"trigger": {"tags_match": "all"}` |
| Only memories carrying every tag (strict isolation, e.g. per-user pages) | Keep `tags` and the `all_strict` default |

To repair a page that already generated empty, `PATCH` it with `{"tags": []}` or with the widened `tags_match`, then refresh it — the tags are stored on the backing mental model, not baked into the content.

See [tag matching modes](./recall#tags) for the full semantics of `any`, `all`, `any_strict`, `all_strict`, and `exact`.

### Default Trigger

When `trigger` is omitted, the page is created with a document-oriented configuration:

```json
{
  "mode": "delta",
  "fact_types": ["observation"],
  "exclude_mental_models": true,
  "refresh_after_consolidation": true
}
```

This makes the page a living document built from consolidated observations only, refreshed incrementally whenever consolidation produces new knowledge in its scope, and never influenced by other pages.

| Setting | Why |
|---|---|
| `fact_types: ["observation"]` | The page reads consolidated beliefs, not the raw conversational noise underneath them. Observations are already deduplicated and evidence-backed, so a page reads as a settled document instead of a transcript. Enforced structurally — with only `observation` in scope, the refresh agent isn't given the raw-memory recall tool at all. |
| `exclude_mental_models: true` | A page never reflects on sibling pages. Without this, pages would cite each other and drift into a feedback loop where one wrong claim propagates across the knowledge base. |
| `mode: "delta"` | Each refresh edits the existing document with what is new since the last refresh instead of regenerating it, so hand-tuned structure and wording survive. See [Refresh Mode](./mental-models#refresh-mode). |
| `refresh_after_consolidation: true` | The page rewrites itself whenever consolidation produces new knowledge in its scope — gated by the same [staleness check](./mental-models#staleness-gating) as any mental model, so unrelated bank activity doesn't trigger rebuilds. |

### Page Lifecycle

1. **Create** — the page is stored with placeholder content and a background refresh is submitted; the call returns immediately with an `operation_id`.
2. **First build** — a full generation, since there is no prior document to edit.
3. **Consolidation** — new memories are retained and consolidated into observations.
4. **Staleness check** — pages whose trigger asks for it are checked against their own scope (tags and `fact_types` both apply).
5. **Delta refresh** — stale pages are rewritten by editing the existing document with the new observations only.

Observations are what a page is *built from*, but it can still inspect the evidence underneath them: the refresh agent can expand a memory to its original chunk or document (unless `store_document_text` is disabled for the bank), and if `HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS` is enabled — off by default — observation search also returns each observation's grounding facts.

:::info
A supplied `trigger` is a **patch**: only the fields you actually send are applied, and the rest keep the defaults above. Sending `{"trigger": {"tags_match": "all"}}` widens the tag filter and leaves `mode`, `fact_types`, `exclude_mental_models`, and `refresh_after_consolidation` as they are. The one exception is the two refresh triggers, which stay mutually exclusive: setting `refresh_cron` clears `refresh_after_consolidation`, and vice versa.
:::

Every [mental model trigger setting](./mental-models#trigger-settings) is accepted here — including `refresh_cron` for scheduled rebuilds instead of consolidation-driven ones, and `tag_groups` for compound tag scoping.

---

## Create a Folder

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="create-folder" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="create-folder" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="create-folder" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="create-folder" language="go" />
</TabItem>
</Tabs>

| Parameter | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Folder name |
| `parent_id` | string | No | Parent folder. Omit or pass `null` for the root. |

A `parent_id` that does not exist, or that points at a page rather than a folder, returns `400`.

---

## Read a Page

Returns the page rendered as a markdown document.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="get-page" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="get-page" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="get-page" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="get-page" language="go" />
</TabItem>
</Tabs>

```json
{
  "id": "kp-2e85...",
  "name": "Deploying the API",
  "type": "runbook",
  "description": "How is the API deployed?",
  "tags": ["ops"],
  "timestamp": "2026-08-03T09:12:44+00:00",
  "body": "# Deploying the API\n\n...",
  "markdown": "---\nid: \"kp-2e85...\"\ntype: \"runbook\"\n...\n---\n\n# Deploying the API\n\n..."
}
```

- `body` is the synthesized markdown body on its own.
- `markdown` is the full document: a YAML frontmatter block (`id`, `type`, `title`, `description`, `tags`, `timestamp`) followed by the body.
- `type` comes from a `type:<x>` tag and defaults to `knowledge-page`. The `type:` tag is removed from the returned `tags`.

---

## Search Pages

Document-level hybrid search: a full-text (BM25) match and a vector-similarity match, fused with Reciprocal Rank Fusion. There is no reranking step, which keeps it fast enough to be an agent's first call.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="search-pages" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="search-pages" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="search-pages" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="search-pages" language="go" />
</TabItem>
</Tabs>

```json
{
  "results": [
    {
      "id": "kp-2e85...",
      "name": "Deploying the API",
      "mental_model_id": "mm-77ab...",
      "snippet": "The API is deployed via ...",
      "score": 0.032,
      "updated_at": "2026-08-03T09:12:44+00:00"
    }
  ],
  "total": 1
}
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `q` | string | — | Required. Search query (min length 1). |
| `limit` | int | `10` | Maximum results, 1–50. |
| `tags` | string[] | — | Only pages carrying these tags, matched per `tags_match`. See [Filter by Tags](#filter-by-tags). |
| `tags_match` | string | `any` | `any`, `all`, `any_strict`, `all_strict`, or `exact`. |
| `tag_groups` | string | — | JSON-encoded compound filter, same shape as recall's `tag_groups`. |

This searches whole pages. To search individual memories, use [recall](./recall).

### Filter by Tags

The tree and search take the same tag filter as [recall](./recall): `tags` with `tags_match`, or a compound `tag_groups` expression (JSON-encoded in the query string, since both are `GET` requests). It matches each page's own tags. Search ranks only among the pages that match, and the tree keeps only matching pages plus the folders above them. A folder with no matching page under it is left out, because its name can reveal as much as its pages.

As in recall, `any` and `all` also return **untagged** pages. Use `any_strict`, `all_strict`, or `exact` when only tagged pages should come back, for example to show a user only their own pages.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="filter-by-tags" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="filter-by-tags" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="filter-by-tags" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="filter-by-tags" language="go" />
</TabItem>
</Tabs>

---

## Update or Move a Node

One `PATCH` renames a node, moves it, and/or updates a page's options. Each field applies only when present in the body.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="update-node" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="update-node" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="update-node" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="update-node" language="go" />
</TabItem>
</Tabs>

| Parameter | Type | Applies to | Description |
|---|---|---|---|
| `name` | string | Both | New name |
| `parent_id` | string \| null | Both | New parent folder. Pass `null` explicitly to move to the root. |
| `source_query` | string | Pages | New question. Changing it schedules an async refresh so the page rebuilds against the new question. |
| `tags` | list | Pages | Replaces the page's tags, and so [the scope it is built from](#tags-are-a-filter). Pass `[]` to clear them and rebuild the page from the whole bank. |
| `max_tokens` | int | Pages | New content budget |
| `trigger` | object | Pages | Refresh settings to change, applied as a patch. `{"tags_match": "all"}` keeps the page's tags but stops excluding untagged memories. |

Sending an empty body returns `400`; an unknown node returns `404`.

---

## Delete a Node

Deletes a folder or page **and its entire subtree**. The mental models backing the deleted pages are removed too.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="delete-node" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="delete-node" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="delete-node" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="delete-node" language="go" />
</TabItem>
</Tabs>

```json
{"status": "deleted"}
```

---

## Export as a Markdown Bundle

Returns the whole knowledge base as a flat set of portable markdown files: a nested `index.md`, one `<page-id>.md` per page, and a `<page-id>.log.md` refresh history for pages that have been rebuilt.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={knowledgePagesPy} section="export" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={knowledgePagesMjs} section="export" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={knowledgePagesSh} section="export" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={knowledgePagesGo} section="export" language="go" />
</TabItem>
</Tabs>

```json
{
  "files": [
    {"path": "index.md", "content": "---\ntype: \"index\"\n..."},
    {"path": "kp-2e85....md", "content": "---\nid: \"kp-2e85...\"\n..."},
    {"path": "kp-2e85....log.md", "content": "---\ntype: \"log\"\n..."}
  ]
}
```

:::tip Mirror it to disk
`hindsight fs mount --bank my-bank` keeps a local folder in sync with this bundle via a background refresh loop, so `ls`, `grep`, `rg`, and your editor work against real files.
:::

---

## Storage

| Table | Holds |
|---|---|
| `knowledge_pages` | The tree: folders and pages, their names, parents, and ordering. A page row references its backing mental model; a folder row has none. |
| `mental_models` | The content: the document body, its source query, tags, token budget, trigger, and refresh history. |

The page layer owns only tree structure — everything about the content lives on the backing mental model, which is why every [mental model](./mental-models) capability applies to pages unchanged.

---

## Endpoint Summary

| Method | Path | Description |
|---|---|---|
| `GET` | `/knowledge-base/tree` | Nested folder/page tree with staleness |
| `POST` | `/knowledge-base/folders` | Create a folder |
| `POST` | `/knowledge-base/pages` | Create a page (async first build) |
| `GET` | `/knowledge-base/pages/{page_id}` | Read a page as markdown |
| `GET` | `/knowledge-base/search` | Hybrid search over pages |
| `PATCH` | `/knowledge-base/nodes/{node_id}` | Rename, move, or reconfigure a node |
| `DELETE` | `/knowledge-base/nodes/{node_id}` | Delete a node and its subtree |
| `GET` | `/knowledge-base/export` | Export the whole base as markdown files |

---

### Hindsight Docs Docs Developer Api Main Methods (`hindsight-docs/docs/developer/api/main-methods.mdx`)

---
sidebar_position: 2
---

# Main Methods

Hindsight provides three core operations: **retain**, **recall**, and **reflect**.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import mainMethodsPy from '!!raw-loader!@site/examples/api/main-methods.py';
import mainMethodsMjs from '!!raw-loader!@site/examples/api/main-methods.mjs';
import mainMethodsSh from '!!raw-loader!@site/examples/api/main-methods.sh';
import mainMethodsGo from '!!raw-loader!@site/examples/api/main-methods.go';

:::tip 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.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mainMethodsPy} section="main-retain" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mainMethodsMjs} section="main-retain" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mainMethodsSh} section="main-retain" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-retain" language="go" />
</TabItem>
</Tabs>

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

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mainMethodsPy} section="main-recall" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mainMethodsMjs} section="main-recall" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mainMethodsSh} section="main-recall" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-recall" language="go" />
</TabItem>
</Tabs>

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

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mainMethodsPy} section="main-reflect" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mainMethodsMjs} section="main-reflect" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mainMethodsSh} section="main-reflect" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-reflect" language="go" />
</TabItem>
</Tabs>

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

---

### Hindsight Docs Docs Developer Api Memories (`hindsight-docs/docs/developer/api/memories.mdx`)

---
sidebar_position: 5
---

# Memories

A **memory unit** is the atomic fact Hindsight extracts and stores. This page covers the endpoints for working with individual memory units — reading and listing them, inspecting how a derived observation evolved, and **curating** them (correcting, retiring, or restoring). Ingesting and querying memories is covered separately in [Retain](./retain.mdx) and [Recall](./recall.mdx).

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import memoriesPy from '!!raw-loader!@site/examples/api/memories.py';
import memoriesMjs from '!!raw-loader!@site/examples/api/memories.mjs';
import memoriesSh from '!!raw-loader!@site/examples/api/memories.sh';
import memoriesGo from '!!raw-loader!@site/examples/api/memories.go';

## Endpoints

| Method | Endpoint | Purpose |
|---|---|---|
| `GET` | `/v1/default/banks/{bank}/memories/list` | List/filter memory units in a bank |
| `GET` | `/v1/default/banks/{bank}/memories/{id}` | Fetch a single memory unit |
| `GET` | `/v1/default/banks/{bank}/memories/{id}/history` | Refresh history of a derived observation |
| `PATCH` | `/v1/default/banks/{bank}/memories/{id}` | Curate: edit / invalidate / restore |
| `DELETE` | `/v1/default/banks/{bank}/memories/{id}/observations` | Clear a memory's derived observations |

## List memory units

List the memory units in a bank. The response includes each unit's `fact_type` (`world` | `experience` | `observation`), `state` (`valid` | `invalidated`), metadata, entities, occurred dates, and — for facts a user has edited — an `edited_at` timestamp. Invalidated rows are **included by default** so curation stays auditable; filter with `state=`.

Narrow the results with query parameters: `type=` (fact type), `q=` (case-insensitive substring filter over text and context — not indexed, so it scans the bank; meant for browsing and inspection, use recall for production search), `document_id=` (a single source document), and `entity_id=` (memory units linked to a given entity). The `entity_id` filter is an exact reverse lookup over stored entity links — not a text or semantic match — so you can list an entity's evidence (e.g. its observations, with `type=observation`) without scanning every memory. Because entity links exist only for live units, combining `entity_id` with `state=invalidated` returns nothing.

### Time window

`start_date=` and `end_date=` restrict the results to a half-open range `[start, end)`, and `time_field=` chooses which timestamp they apply to:

| `time_field` | Means |
|---|---|
| `created_at` (default) | When the memory was ingested |
| `updated_at` | When it was last written |
| `mentioned_at` | When the fact was mentioned |
| `occurred_start` / `occurred_end` | When the fact itself occurred |

`time_field` also becomes the sort order (newest first), and **memories with no value on that column are excluded** — so `total` counts only the memories carrying that timestamp, and can be `0` on a bank that is not empty. Omit all three parameters to keep the default listing, which orders by `mentioned_at` then `created_at` and drops nothing.

This filters, unlike recall's `temporal_window`, which only ranks — use it when you need an exhaustive, time-bounded read rather than the best matches.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="list-memories" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="list-memories" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="list-memories" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="list-memories" language="go" />
</TabItem>
</Tabs>

## Fetch a single memory unit

Fetch a memory unit by ID, including its content, metadata, entities, timestamps, tags, and curation state.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="get-memory" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="get-memory" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="get-memory" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="get-memory" language="go" />
</TabItem>
</Tabs>

For a **derived observation**, the history endpoint returns how it was refreshed over time as new source facts arrived:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="observation-history" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="observation-history" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="observation-history" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="observation-history" language="go" />
</TabItem>
</Tabs>

## Curation: editing, invalidating & pruning

Memory is append-only by design — but sometimes a stored fact is **wrong**, has gone **stale**, or is a **duplicate**. Curation lets you correct or retire individual memories without losing the audit trail. Retired facts are moved out of the active set, so recall never returns them, while remaining fully recoverable.

### When to reach for what

Not every "bad memory" needs the same tool. Pick by *why* it's bad:

| The memory is… | Use | Why |
|---|---|---|
| **Wrong because the whole bank extracts badly** (e.g. consistently wrong subject) | Fix the bank's `retain_mission` / `observations_mission`, then **reprocess** the document | Systematic problems are best fixed at the source, then replayed — see [Retain](./retain.mdx) and [Observations](../observations.mdx). |
| **Wrong as a one-off** (a single misextracted fact) | **Edit** the memory | Corrects the fact and regenerates everything derived from it. |
| **No longer true, with nothing to replace it** (decommissioned server, a tool that was fixed, a role that changed) | **Invalidate** the memory | Nothing in the pipeline knows the world changed, so you tell it explicitly. |
| **A duplicate or superseded fact** | **Invalidate** the memory | Removes the noise from recall while keeping the audit trail. |
| **Superseded by a newer fact you're storing anyway** (e.g. "likes BMW" → "likes Toyota") | Just retain the new fact | Consolidation already reconciles in-stream contradictions into a single observation. |

The rule of thumb: **if Hindsight could have known, let consolidation handle it; if only you know, curate it.**

Only raw **world** and **experience** facts can be curated. Observations are *derived* — they regenerate from their sources, so you curate the underlying facts, not the observation. A `PATCH` on an observation returns `400`.

### Edit a memory

Correct what the LLM extracted. You can change the **text**, **context**, **occurred dates**, **mention time** (`mentioned_at`), **fact type**, and **entities** — anything the extractor could have gotten wrong. Hindsight re-embeds the fact, drops the observations and links derived from the old version, and re-consolidates, so downstream knowledge reflects the correction. Edited facts are marked with an `edited_at` timestamp (surfaced as an **Edited** badge in the control plane).

Correcting a fact never discards the **cause-and-effect relationships** it takes part in: those come from reading the original source, so an edit (or an invalidate/restore round-trip) keeps them intact rather than dropping links nothing could rebuild.

You don't need to rebuild anything yourself: an edit **automatically recomputes the knowledge graph and links** in the background. The fact's entity associations are re-resolved from the new text/entities, its temporal and semantic links are re-derived, and consolidation re-runs — all triggered by the edit. The PATCH returns as soon as the change is committed; the graph/observation rebuild happens asynchronously right after.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="edit-memory" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="edit-memory" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="edit-memory" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="edit-memory" language="go" />
</TabItem>
</Tabs>

You can correct the dates, fact type, and entities the same way. For `context`, `occurred_start`, `occurred_end`, and `mentioned_at`, an empty string `""` clears the field and omitting it leaves it unchanged. For `entities`, a list **replaces** the fact's entity set and `[]` detaches them all; omitting it leaves them unchanged.

### Resolving entity names

`resolve_entities` controls how the names in `entities` are matched to entities in the bank:

| Value | Behaviour |
| --- | --- |
| `true` (default) | What retain does. Each name is resolved against the bank, so a name close to one already there may resolve to that existing entity instead, based on name similarity plus how strongly it co-occurs with the other names you sent. |
| `false` | The names are taken literally. An existing entity is reused only when its name matches case-insensitively, any other name creates a new entity, and names in the same request are never merged with each other. |

**Pass `false` when you are correcting a fact by hand.** With resolution on, a name that is close to one already in the bank can be matched onto that neighbour rather than the entity you named — `Dr. Waller` onto a `Dr Wall` typo, `Alice Smith` onto `Alice` — and because the edit succeeds normally the substitution is not obvious from the response. Resolution is right for names that came out of extraction, where spelling varies and the bank's existing entity is usually the one meant; it is wrong when you already know which entity you want. The default stays `true` so existing callers are unaffected.

The same flag exists on [retain](./retain#resolve_entities) for the entities you supply there.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="edit-memory-fields" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="edit-memory-fields" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="edit-memory-fields" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="edit-memory-fields" language="go" />
</TabItem>
</Tabs>

### Invalidate a memory (reversible)

Soft-retire a fact. An invalidated memory:

- **disappears from recall**, consolidation, and the knowledge graph,
- has its **links pruned** and its **derived observations re-computed** without it (cause-and-effect relationships are kept aside and come back if you restore it),
- **stays in the bank** for audit (visible via the memory and document views), and
- can be **restored** at any time.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="invalidate-memory" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="invalidate-memory" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="invalidate-memory" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="invalidate-memory" language="go" />
</TabItem>
</Tabs>

Restoring moves the fact back into the active set, brings back the cause-and-effect relationships it took part in, and re-consolidates:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoriesPy} section="restore-memory" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoriesMjs} section="restore-memory" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoriesSh} section="restore-memory" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoriesGo} section="restore-memory" language="go" />
</TabItem>
</Tabs>

Behind the scenes, invalidating **moves** the row out of the active `memory_units` table into a separate archive, so recall and consolidation never need a "skip invalidated" filter — the rows simply aren't there.

:::note Documents are the source of truth
A memory is extracted from a document. Editing or invalidating a memory does **not** change the document it came from — that's deliberate: the document stays as an accurate historical record. As a result, **reprocessing a document resets curation** of the facts it produced (extraction runs fresh from the original text). Fix systematic issues at the mission level and reprocess; use edit/invalidate for the residue.
:::

### A pruning workflow

To clean up duplicates and reclaim noise: cluster duplicates from `memories/list`, then **invalidate** them — recall is clean immediately, and the audit trail is preserved.

---

### Hindsight Docs Docs Developer Api Memory Banks (`hindsight-docs/docs/developer/api/memory-banks.mdx`)

---
sidebar_position: 6
---

# Memory Banks

Memory banks are isolated containers that store all memory-related data for a specific context or use case.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import memoryBanksPy from '!!raw-loader!@site/examples/api/memory-banks.py';
import memoryBanksMjs from '!!raw-loader!@site/examples/api/memory-banks.mjs';
import memoryBanksSh from '!!raw-loader!@site/examples/api/memory-banks.sh';
import memoryBanksGo from '!!raw-loader!@site/examples/api/memory-banks.go';
import directivesPy from '!!raw-loader!@site/examples/api/directives.py';
import directivesMjs from '!!raw-loader!@site/examples/api/directives.mjs';
import directivesSh from '!!raw-loader!@site/examples/api/directives.sh';
import directivesGo from '!!raw-loader!@site/examples/api/directives.go';

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

Only *writes* create a bank — retaining, updating its profile, changing its config. **Reads of a
bank that does not exist return `404`**, so a typo'd, renamed or deleted `bank_id` is reported
rather than answered with empty results. That matters if you are monitoring a bank: `GET .../stats`
on a missing bank fails loudly instead of returning zeroed counters that look like a healthy,
empty bank.

:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::

## Creating a Memory Bank

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="create-bank" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="create-bank" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="create-bank" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="create-bank" language="go" />
</TabItem>
</Tabs>

## Aliases {#aliases}

An **alias** is an extra id a bank answers to. Every endpoint that accepts a bank id accepts its aliases too, so an alias is a second way in to the same bank — nothing is copied, moved or duplicated.

This exists because a bank's id is the key on all of its data, so changing it means rewriting every row and cutting all clients over at once (that is [`rename-bank`](/developer/admin-cli#rename-bank), and it needs downtime). An alias lets you do the same migration in phases: add the new id, move clients across a few at a time while both ids work, and stop when nothing calls the old one.

```
POST   /v1/default/banks/{bank_id}/aliases             {"alias": "new-id"}
GET    /v1/default/banks/{bank_id}/aliases
PATCH  /v1/default/banks/{bank_id}/aliases/{alias}     {"primary": true}
DELETE /v1/default/banks/{bank_id}/aliases/{alias}
```

### Showing a bank under an alias

Once callers have moved, the control plane can present the bank under the new id instead of the one it was created with. `PATCH` with `primary: true` promotes an alias; `primary: false` returns the bank to showing its own id without removing the alias.

This is **display only**. `bank_id` remains the bank's identity: authorisation, metering, exports and audit logs all continue to use it, and it is what every response reports. The control plane shows the real id alongside the alias so the two are never confused.

- **Optional, with no default.** A bank's own id is not an alias, so "not shown under an alias" is the normal state.
- **At most one.** Promoting an alias demotes whichever one was shown before, in a single transaction.
- **It cannot outlive the alias.** Deleting the promoted alias returns the bank to showing its own id.

**What to know:**

- **A bank can have several aliases**, so more than one old id can be retired at a time.
- **A name is unique across banks and aliases.** Adding one that already names a bank or another alias returns `409`, so an alias can never reach two banks.
- **An alias never replaces the bank's own id.** The real id keeps working and is what every response reports, including responses to requests made through an alias. Removing a bank's real id is not possible — that is what `rename-bank` is for.
- **Deleting an alias only closes that door.** The bank and its memories are untouched, and callers still using the removed id get exactly what they got before it existed.
- **Deleting a bank removes its aliases**, freeing those names for reuse.
- **Aliases are not carried by export, import or clone.** They describe which ids reach a bank on *this* deployment, so a copy does not inherit them — add them to the target deliberately.
- **A change takes a few seconds to reach every API replica.** See [`HINDSIGHT_API_BANK_ALIAS_CACHE_TTL_SECONDS`](/developer/configuration#bank-alias-cache).

## Bank Configuration

Each memory bank can be configured independently per operation. Configuration can be set via the [bank config API](#updating-configuration), the [Control Plane UI](/), or [server-wide environment variables](/developer/configuration).

### retain_mission {#retain-configuration}

A plain-language description of what this bank should pay attention to during extraction. The mission is injected into the extraction prompt alongside the built-in rules — it steers focus without replacing the extraction logic.

```
e.g. Always include technical decisions, API design choices, and architectural trade-offs.
     Ignore meeting logistics, greetings, and social exchanges.
```

Works alongside any extraction mode. Leave blank for general-purpose extraction.

### retain_extraction_mode

Controls how aggressively facts are extracted:

| Mode | Description |
|------|-------------|
| `concise` *(default)* | Selective — only facts worth remembering long-term |
| `verbose` | Captures more detail per fact; slower and uses more tokens |
| `custom` | Write your own extraction rules via `retain_custom_instructions` |
| `verbatim` | Stores each chunk's original text as one memory; the LLM extracts metadata such as entities and dates |
| `chunks` | Stores each chunk as one memory without an LLM call; only caller-provided entities are available |

### retain_custom_instructions

Only active when `retain_extraction_mode` is `custom`. Replaces the built-in extraction rules entirely with your own instructions.

### retain_chunk_size

Maximum number of characters per chunk when splitting content for fact extraction. Larger chunks mean fewer LLM calls but may reduce extraction quality on long inputs; smaller chunks improve granularity at the cost of more calls.

Default: `3000`

### retain_structured_chunk_size

Maximum number of characters for a single JSONL line or conversation turn to keep whole when it exceeds `retain_chunk_size`. When unset, the limit is exactly `retain_chunk_size`; set a larger value for structured logs or chat transcripts where splitting a single record would lose useful context.

Default: unset, which uses `retain_chunk_size`

See [Retain configuration](/developer/configuration#retain) for environment variable names and defaults.

### entity_labels {#entity-labels}

Defines a controlled vocabulary of `key:value` classification labels extracted at retain time and stored as entities. Because labels become entities, they automatically link memories in the knowledge graph (two memories with `pedagogy:scaffolding` are linked), improve semantic and BM25 retrieval, and optionally filter memories via the standard `tags`/`tags_match` API when `tag: true` is set on a group.

Each entry in `entity_labels` is a **label group** — one classification dimension:

```json
{
  "entity_labels": [
    {
      "key": "engagement",
      "description": "Student engagement level during the session",
      "type": "value",
      "optional": true,
      "values": [
        { "value": "active",  "description": "Student is actively participating" },
        { "value": "passive", "description": "Student is listening but not participating" }
      ]
    },
    {
      "key": "pedagogy",
      "description": "Teaching strategies used",
      "type": "multi-values",
      "values": [
        { "value": "scaffolding",          "description": "Breaking complex tasks into smaller steps" },
        { "value": "direct_instruction",   "description": "Explicit explanation by the teacher" },
        { "value": "socratic_questioning", "description": "Guiding through questions rather than answers" }
      ]
    }
  ]
}
```

| Field | Default | Description |
|-------|---------|-------------|
| `key` | — | Label group identifier. Becomes the prefix in `key:value` entities (or `key:field:value` for `"map"`). |
| `description` | `""` | Shown to the LLM to guide label assignment. |
| `type` | `"value"` | `"value"` → pick one enum value; `"multi-values"` → pick multiple; `"text"` → free-form string; `"multi-text"` → any number of free-form strings; `"map"` → structured group with named fields. |
| `values` | `[]` | Allowed values for `"value"` and `"multi-values"` types. Ignored for `"text"`, `"multi-text"` and `"map"`. |
| `fields` | `{}` | Field definitions for `"map"` types. Each field is itself typed (`"text"`, `"multi-text"`, `"value"`, `"multi-values"`, or nested `"map"`). Ignored for non-map types. |
| `optional` | `true` | When `true` the LLM may skip the label if not applicable. When `false` the LLM must always assign a value. Has no effect on `"multi-values"` or `"multi-text"` groups (always optional). |
| `tag` | `false` | When `true`, extracted `key:value` labels are also written as tags on the memory unit, enabling filtering via `tags`/`tags_match` in recall/reflect. |

**Enum groups** (`type: "value"` or `type: "multi-values"`): the LLM picks from the predefined `values` list; anything outside the list is silently dropped. Vocabulary stays stable and graph links stay tight. Use `"multi-values"` when a fact can belong to several values at once.

**Free-text groups** (`type: "text"`): the LLM writes any string. Use the `description` field to provide examples and guidance. Graph clustering is less reliable than with enum groups because the model may phrase the same concept differently across sessions.

```json
{
  "key": "topic",
  "description": "Specific subject being discussed. Examples: algebra, quadratic equations, geometry.",
  "type": "text",
  "optional": true,
  "values": []
}
```

**Open-vocabulary groups** (`type: "multi-text"`): like `"text"`, but the LLM writes a *list* of strings instead of one — so a single fact can carry several values that nobody enumerated in the bank config. Use it when the interesting values only exist in the content: the names a thing is known by (canonical name plus abbreviations, acronyms and alternative spellings), ticket references a fact cites, product codes or SKUs. Each value becomes its own `key:value` entity, and with `tag: true` each is written as a tag, so a bank can derive a classification from content and then filter on it at recall without the caller supplying the vocabulary.

```json
{
  "key": "name",
  "description": "Every name the subject of this fact is known by — canonical name plus abbreviations, acronyms, short forms and alternative spellings.",
  "type": "multi-text",
  "tag": true
}
```

Retaining *"We deploy services to a Kubernetes cluster on EKS — the team usually just says k8s, or kube"* yields the entities and tags `name:kubernetes`, `name:k8s` and `name:kube`, so `{"tags": ["name:k8s"], "tags_match": "any_strict"}` finds the fact. A `"text"` group would keep only one of the three.

**Map groups** (`type: "map"`): defines a structured entity type with named fields. Each field is itself typed (`"text"`, `"multi-text"`, `"value"`, `"multi-values"`, or nested `"map"`) so you can describe rich entities like a person with name, role, and organization. Each extracted field is stored as a flat `key:field:value` entity string (e.g. `person:name:Alice`), reusing the existing entity storage with no schema changes — so map fields participate in the knowledge graph and retrieval the same way single-value labels do.

```json
{
  "key": "person",
  "description": "A person mentioned in the text",
  "type": "map",
  "fields": {
    "name":         { "type": "text", "description": "Full name of the person" },
    "role":         { "type": "text", "description": "Job title or role" },
    "organization": { "type": "text", "description": "Company or organization" }
  }
}
```

**How label entities resolve.** Regular entities resolve fuzzily so close name variants merge ("Alice" / "Alice Chen"). Label entities are different: their canonical names are user-defined, so two similar-looking values (`use:use-001` / `use:use-002`) must stay distinct. They therefore resolve by **exact match only** and are stored with `entity_kind = "label"`, which keeps them out of fuzzy name matching entirely — a free-text label group accumulating thousands of similar values doesn't slow down resolution of the bank's regular entities. The classification is fixed when the entity is first stored; removing a label group later doesn't reclassify its existing entities.

### entities_allow_free_form

By default, entity labels are extracted **alongside** regular named entities (people, places, concepts). Set to `false` to disable free-form extraction so only label entities are stored:

```json
{
  "entity_labels": [...],
  "entities_allow_free_form": false
}
```

### enable_observations {#observations-configuration}

Toggles observation consolidation on or off. When `false`, no consolidation runs for this bank — neither automatic nor manual. Defaults to `true` when the observations feature is enabled on the server.

### enable_auto_consolidation

Controls whether consolidation runs automatically after retain, delete, and update operations. When `false`, consolidation only runs when explicitly triggered via the [consolidate endpoint](/developer/observations#trigger-consolidation). Defaults to `true`.

This is useful when you want full control over consolidation timing — for example, batching many retains before consolidating, or running [targeted consolidation](/developer/observations#targeted-consolidation) for specific scopes only.

### default_observation_scopes {#default_observation_scopes}

The [`observation_scopes`](./retain#observation_scopes) applied to retained items that send none. Defaults to `combined`, which groups each memory by its full tag set. Set it to `[["topic:*"]]` to group memories by their own `topic:` tags only — e.g. ones added by an [entity label](#entity-labels) with `tag: true` — so memories about the same subject from different agents or sessions consolidate together without changing any client. Stored on each memory at retain, so changing it doesn't re-scope memories already retained — until their document is reprocessed, which applies the current default.

### observations_mission

Defines what this bank should synthesise into durable observations. Replaces the built-in consolidation rules entirely — leave blank to use the server default.

```
e.g. Observations are stable facts about people and projects.
     Always include preferences, skills, and recurring patterns.
     Ignore one-off events and ephemeral state.
```

### consolidation_llm_batch_size

Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Leave unset to use the server default (`8`).

### consolidation_source_facts_max_tokens

Total token budget for source facts included with observations in the consolidation prompt. Source facts give the LLM evidence to compare new facts against existing observations. `-1` = unlimited. Leave unset to use the server default (`-1`).

### consolidation_source_facts_max_tokens_per_observation

Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts, preventing a single observation with many source facts from consuming the entire budget. `-1` = unlimited. Leave unset to use the server default (`256`).

See [Observations configuration](/developer/configuration#observations) for environment variable names and defaults.

### reflect_mission

A first-person narrative that provides identity and framing context for `reflect`. The agent uses this to ground its reasoning and apply a consistent perspective.

```
e.g. You are a senior engineering assistant.
     Always ground answers in documented decisions and rationale.
     Ignore speculation. Be direct and precise.
```

### disposition_skepticism

How skeptical vs trusting the bank is when evaluating claims during `reflect`. Scale 1–5.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="bank-with-disposition" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="bank-with-disposition" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="bank-with-disposition" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="bank-with-disposition" language="go" />
</TabItem>
</Tabs>

| Value | Behaviour |
|-------|-----------|
| `1` | Trusting — accepts information at face value |
| `3` *(default)* | Balanced |
| `5` | Skeptical — questions and doubts claims |

### disposition_literalism

How literally to interpret information during `reflect`. Scale 1–5.

| Value | Behaviour |
|-------|-----------|
| `1` | Flexible — reads between the lines, considers context |
| `3` *(default)* | Balanced |
| `5` | Literal — takes things exactly as stated |

### disposition_empathy

How much to weight emotional context when reasoning during `reflect`. Scale 1–5.

| Value | Behaviour |
|-------|-----------|
| `1` | Detached — focuses on facts and logic |
| `3` *(default)* | Balanced |
| `5` | Empathetic — considers emotional context |

:::info
Disposition traits and `reflect_mission` only affect the `reflect` operation. `retain_mission` and `observations_mission` are separate per-operation settings.
:::

### mcp_enabled_tools

An allowlist of MCP tool names that are enabled for this bank. When set, only the listed tools can be invoked; any tool not in the list returns an error (tools still appear in the MCP tools list for protocol compatibility). Set to `null` (or omit) to allow all tools.

```json
["recall", "reflect"]
```

Available tool names: `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`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`, `get_knowledge_base_tree`, `search_knowledge_base`, `get_knowledge_page`, `create_knowledge_folder`, `create_knowledge_page`, `update_knowledge_node`, `delete_knowledge_node`.

### llm_gemini_safety_settings

Controls content filtering thresholds for Gemini and VertexAI providers. Accepts a list of safety setting objects in the [Google AI safety settings format](https://ai.google.dev/api/generate-content#v1beta.SafetySetting). When `null` (default), Gemini's built-in safety defaults are used.

```json
[
  {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
  {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}
]
```

Only applies when `HINDSIGHT_API_LLM_PROVIDER` is `gemini` or `vertexai`.

### recall_budget_function {#recall-budget-configuration}

Selects how the [`recall` request's `budget` parameter](./recall) (`low` / `mid` / `high`) maps to the internal `thinking_budget` integer used by every retrieval method (semantic, BM25, graph, temporal). Two functions are supported:

| Function | Behaviour |
|----------|-----------|
| `fixed` *(default)* | `thinking_budget = recall_budget_fixed_<level>` — independent of `max_tokens`. Preserves legacy behavior. |
| `adaptive` | `thinking_budget = round(max_tokens * recall_budget_adaptive_<level>)`, clamped to `[recall_budget_min, recall_budget_max]`. Retrieval breadth scales with the requested output size. |

```json
{
  "recall_budget_function": "adaptive",
  "recall_budget_adaptive_low": 0.05,
  "recall_budget_adaptive_mid": 0.1,
  "recall_budget_adaptive_high": 0.3,
  "recall_budget_min": 30,
  "recall_budget_max": 1500
}
```

### recall_budget_fixed_low / recall_budget_fixed_mid / recall_budget_fixed_high

When `recall_budget_function` is `fixed` (the default), these positive integers are used directly as the per-method retrieval limit for each `budget` level. Defaults: `100` / `300` / `1000` — exactly matching the legacy hardcoded mapping.

### recall_budget_adaptive_low / recall_budget_adaptive_mid / recall_budget_adaptive_high

When `recall_budget_function` is `adaptive`, these positive ratios multiply the request's `max_tokens` to derive the per-method retrieval limit. Defaults: `0.025` / `0.075` / `0.25` — chosen to roughly match the fixed defaults at `max_tokens = 4096`.

### recall_budget_min / recall_budget_max

Floor and ceiling applied to the result of the adaptive function (after the ratio multiplication). Both must be positive integers and `min ≤ max`. Defaults: `20` / `2000`.

See [Recall budget mapping](/developer/configuration#recall-budget-mapping) for environment variable names and full defaults.

### memory_defense {#memory_defense}

Per-bank Memory Defense policy. Defaults to absent (Memory Defense disabled on this bank).

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | bool | `false` | Master switch. |
| `default_action` | `allow`\|`redact`\|`quarantine`\|`block` | `allow` | Fallback action when no rule matches. |
| `protected_tag_namespaces` | `list[str]` | `[]` | Writes with tags in these namespaces (`ns:*`) are subject to the `protected_key` detector. |
| `immutable_tag_namespaces` | `list[str]` | `[]` | Writes to these namespaces are blocked. |
| `rules` | `list[Rule]` | `[]` | Detector-to-action mappings (see below). |
| `detector_overrides` | `dict` | `{}` | Per-detector tuning (e.g. `size_anomaly.max_size`). |

`Rule` shape:

| Field | Required | Description |
|---|---|---|
| `on` | yes | Detector name (`prompt_injection`, `sensitive_data`, `protected_key`, `immutable_key`, `size_anomaly`) or `*` for any. |
| `action` | yes | One of `allow`, `redact`, `quarantine`, `block`. |
| `min_severity` | no | Minimum severity (`low`, `medium`, `high`, `critical`) for the rule to fire. Defaults to `low`. |

Invalid policies are rejected on PATCH with HTTP 422.

See the [Memory Defense guide](../memory-defense/index.md) for usage examples.

---

## Previewing Prompts

A mission only means something once you can see the prompt it lands in. `POST /v1/default/banks/{bank_id}/prompts/preview` renders the exact messages `retain`, `consolidation` or `reflect` would send for a bank — no LLM call, no reads, nothing stored.

The operation is the whole request. Everything that shapes the prompt is read from the bank — its resolved config, profile and directives — and the runtime data an operation would be given is a fixed bracketed placeholder:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="prompts-preview" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="prompts-preview" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="prompts-preview" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="prompts-preview" language="go" />
</TabItem>
</Tabs>

For `retain`, add `"strategy": "<name>"` to render under one of the bank's named retain strategies. Omit it and the bank's `retain_default_strategy` applies — exactly as it does for a retain that names none — so what you see is what retain would send. The response echoes the strategy that applied in `strategy`, and lists the bank's strategy names in `strategies` so a picker needs no second call.

There is deliberately nothing to override. A preview answers "what does this bank send"; letting a caller pass its own mission or sample text only moves that question somewhere the bank cannot answer it. To try a candidate value, save it and look again — the response says which settings are editable.

The response carries the messages in send order, each broken into the blocks it is built from:

```json
{
  "messages": [
    {
      "role": "system",
      "blocks": [
        {
          "text": "Extract SIGNIFICANT facts from text...",
          "source": "builtin",
          "field": "retain_extraction_mode",
          "section": "",
          "heading": "Selectivity",
          "active": true,
          "value": "concise",
          "kind": "choice",
          "choices": ["concise", "verbose", "verbatim", "chunks", "custom"],
          "editable": true
        },
        {
          "text": "",
          "source": "config",
          "field": "retain_custom_instructions",
          "section": "",
          "heading": "",
          "active": false,
          "kind": "text",
          "editable": true
        }
      ]
    },
    { "role": "user", "blocks": [] }
  ],
  "response_schema": { "type": "object", "properties": { "facts": {} } }
}
```

- **`messages`** — the request, system first, exactly as the model receives it.
- **`blocks`** — the **active** blocks of a message concatenate back to its text exactly: nothing dropped, duplicated or reordered. `source` says what produced each: `config` for a setting you can change, `builtin` for Hindsight's own wording. The runtime data an operation is given is not a block — it is a hole in the text, marked inline with `«…»`.
- **Inactive blocks** (`active: false`) have no text. They mark a setting that is switched off, at the point where it *would* land — so a mission you have not written yet is still visible where it would go.
- **Identifying a block** — whichever of these applies, in order: `field` is the config field behind it; `section` is a slug for a part no single field owns (`bank_identity`, `disposition`, `directives`); `heading` is the section heading the prompt text itself carries there. All three are machine values. The response carries **no display copy** — what a block is called, and what turning a switched-off one on would do, is for the client to say in the language it is running in.
- **`editable`** — false for server-level fields such as `llm_output_language`, which shape the prompt but cannot be overridden per bank.

**Both messages are returned on purpose.** For `retain` and `consolidation` the mission travels in the *user* message, not the system prompt: keeping the system prompt identical for every bank lets one provider-side prompt cache serve them all, which is a large cost saving on high-volume ingestion. Only `reflect` puts its mission in the system prompt.

### When there is no prompt

`chunks` extraction mode stores each chunk verbatim and never calls an LLM. There is no prompt to show, so `messages` is empty and `skipped_reason` explains why:

```json
{
  "operation": "retain",
  "messages": [],
  "skipped_reason": "Chunks mode stores each chunk verbatim as its own memory and never calls an LLM, ..."
}
```

In the Control Plane, each Mission field on the bank **Configuration** tab has a **Preview prompt** button, and each block's setting can be edited there and saved to the bank.

:::tip
[Dry-run extraction](/developer/api/memories) is the paid counterpart: it spends a real LLM call to show what the same configuration actually *extracts*. It resolves its config the same way, strategy included, so the two agree. The Control Plane pairs them in one prompt tester.
:::

## Updating Configuration

Bank configuration fields (retain mission, extraction mode, observations mission, etc.) are managed via a **separate config API**, not the `create_bank` call. This lets you change operational settings independently from the bank's identity and disposition.

### Setting Configuration Overrides

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="update-bank-config" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="update-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="update-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="update-bank-config" language="go" />
</TabItem>
</Tabs>

You can update any subset of fields — only the keys you provide are changed.

### Reading the Current Configuration

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="get-bank-config" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="get-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="get-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="get-bank-config" language="go" />
</TabItem>
</Tabs>

The response distinguishes:
- **`config`** — the fully resolved configuration (server defaults merged with bank overrides)
- **`overrides`** — only the fields explicitly overridden for this bank

### Resetting to Defaults

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="reset-bank-config" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="reset-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="reset-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="reset-bank-config" language="go" />
</TabItem>
</Tabs>

This removes all bank-level overrides. The bank reverts to server-wide defaults (set via environment variables).

You can also update configuration directly from the [Control Plane UI](/) — navigate to a bank and open the **Configuration** tab.

---

## 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 enforced whenever they are in scope (see [Directive Scope and Tags](#directive-scope-and-tags)).

:::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"

### Directive Scope and Tags

Directives can carry `tags`, and those tags scope **when** a directive is applied during `reflect` — mirroring how tags scope memories:

- **Untagged directives always apply**, on every `reflect`.
- **Tagged directives apply only when the `reflect` request includes matching tags** (using the request's `tags_match` mode). A `reflect` call with no tags applies only the untagged directives.

To apply **every** active directive regardless of tags, set `apply_all_directives: true` on the `reflect` request. This ignores tag scope for directives (untagged and tagged alike are enforced) and is useful when an operator keeps tagged directives for organization but wants all of them enforced on an untagged reflection.

### Creating Directives

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={directivesPy} section="create-directive" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="create-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="create-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="create-directive" language="go" />
</TabItem>
</Tabs>

### Listing Directives

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={directivesPy} section="list-directives" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="list-directives" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="list-directives" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="list-directives" language="go" />
</TabItem>
</Tabs>

### Updating Directives

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={directivesPy} section="update-directive" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="update-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="update-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="update-directive" language="go" />
</TabItem>
</Tabs>

### Deleting Directives

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={directivesPy} section="delete-directive" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="delete-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="delete-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="delete-directive" language="go" />
</TabItem>
</Tabs>

### 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 |

---

## Bank transfer (export & import)

Move a bank — or just its documents — between banks and instances **without re-running the LLM**. One archive format, one pair of endpoints, and three flags that decide what travels:

| Flag | Default | What it carries |
|------|---------|-----------------|
| `include_data` | `true` | Documents, raw chunks, extracted facts, consolidated observations, entities and links, attachments **and their bytes**, the curation archive of invalidated facts, the operations log, the maintenance queues — and what the bank synthesized from all of it: mental models, their refresh history, and the knowledge-page tree over them. |
| `include_bank_config` | `true` | How the bank is set up: its config overrides, directives, and webhooks. |
| `include_history` | `false` | `audit_log` and `llm_requests`. |

Mental models and knowledge pages count as **data**, not configuration. A mental model is a reading of the bank's facts and cites them by id in its `based_on` evidence, so carrying it without them would restore a synthesis whose grounding resolves to nothing. Their refresh history follows them for the same reason.

Embeddings and database ids are never carried: facts are re-embedded with the *target* bank's model and entities are re-resolved against it, so an archive moves cleanly to an instance configured with a different embedding model, vector extension, or text-search backend.

Webhooks travel with `include_bank_config`. When copying a bank whose webhooks point at a per-bank consumer, export with `include_bank_config=false`, or delete them on the copy.

### Export

`POST /v1/default/banks/{bank_id}/transfer/export` — runs as a **background operation** (a whole-bank export loads every unit and compresses a large archive). Returns `202` with an `operation_id`; poll the bank's operations endpoint, then download the archive from the `download_url` in `result_metadata`.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="transfer-export" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="transfer-export" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="transfer-export" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="transfer-export" language="go" />
</TabItem>
</Tabs>

A document subset must also pass `include_bank_config=false` (and leave `include_history` off). Bank-level sections cannot be scoped to documents, so the server rejects that combination with `400`.

### Import

`POST /v1/default/banks/{bank_id}/transfer/import` — multipart upload (`file` = the ZIP), also a background operation.

| Mode | Behaviour |
|------|-----------|
| `restore` (default) | Writes a whole bank into `target_bank_id`, which **must not already exist**. This is how a bank is moved to another instance, or copied under a new id. |
| `merge` | Folds the archive's documents into `{bank_id}`. `document_conflict` decides what happens to document ids that already exist: `skip` (default), `replace`, `new-id`. |

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="transfer-import" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="transfer-import" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="transfer-import" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="transfer-import" language="go" />
</TabItem>
</Tabs>

The include flags apply here too, and can only narrow: they restore a subset of what the archive holds, never more.

In `restore` mode the operation is recorded against `{bank_id}` — the bank in the URL — because the target bank does not exist yet. Poll that bank's operations endpoint for status and the per-component counts.

A restore carries the operations log as history, not as work: anything still in flight when the bank was exported is left behind, so a copied bank never re-runs the original's queued retains or re-fires its webhooks. In-flight work belongs to the bank that was exported — and a clone runs *inside* one such operation, so carrying them would put the clone's own unfinished record in the copy.

### Import facts extracted outside Hindsight

An archive doesn't have to come from an export. If you run your own extraction pipeline, build the archive yourself and import it with `mode=merge`: your chunks and facts are stored as given — no LLM call, no re-chunking — while Hindsight still re-embeds the facts, resolves their entities against the bank, builds the semantic, temporal, entity and causal links, and — like a retain — fires `retain.completed` webhooks and auto-consolidation when the bank has them enabled.

The archive is a ZIP with a `manifest.json` and one JSON file per document under `documents/`:

```text
import.zip
├── manifest.json            {"schema_version": 1, "source_bank_id": "external"}
└── documents/
    └── session-2026-09-22.json
```

A document file:

```json
{
  "id": "session-2026-09-22",
  "original_text": "Full original session text...",
  "tags": ["source:pi"],
  "chunks": [{ "chunk_index": 0, "chunk_text": "Caller-defined source region..." }],
  "facts": [
    {
      "text": "The user prefers lightweight local speech recognition models.",
      "fact_type": "experience",
      "chunk_index": 0,
      "context": "Discussion of local speech recognition",
      "mentioned_at": "2026-09-22T18:34:00Z",
      "occurred_start": "2026-09-22T18:34:00Z",
      "occurred_end": "2026-09-22T18:34:00Z",
      "entities": ["Parakeet"],
      "metadata": { "source_turn": "143" },
      "tags": ["source:pi"],
      "observation_scopes": "shared",
      "causal_relations": []
    }
  ]
}
```

| Fact field | Description |
|------------|-------------|
| `text` | Required. The memory, as it will be recalled. |
| `fact_type` | Required. `world` or `experience`. |
| `chunk_index` | Optional. The chunk in `chunks` this fact came from. |
| `context`, `metadata`, `tags`, `observation_scopes` | Optional. Same meaning as on a retain item, set per fact. |
| `mentioned_at`, `occurred_start`, `occurred_end` | Optional ISO 8601 dates. With none of them set, the fact is dated at import time. |
| `entities` | Optional entity names, resolved against the bank's existing entities. |
| `causal_relations` | Optional. `[{"relation_type": "caused_by", "target_fact_index": N}]`, where `N` is the position of another fact in the same document's `facts` list. |

Metadata and dates live on each fact; a document carries no metadata or timestamp of its own. Re-sending a document id follows `document_conflict` (`replace` to overwrite it).

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="transfer-import-external" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="transfer-import-external" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="transfer-import-external" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="transfer-import-external" language="go" />
</TabItem>
</Tabs>

### Clone a bank

`POST /v1/default/banks/{bank_id}/clone` — copy a bank into a new one in a single call, without handling an archive yourself. This is the export and import above run back to back on this instance: nothing is re-extracted and no LLM is called, so the clone's facts are exactly the source's, re-embedded with the same model.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="clone-bank" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="clone-bank" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="clone-bank" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="clone-bank" language="go" />
</TabItem>
</Tabs>

| Query param | Default | Description |
|-------------|---------|-------------|
| `target_bank_id` | — | Required. The bank to create; it must not already exist. |
| `include_data` | `true` | Copy the memories, everything backing them, and the mental models and knowledge pages synthesized from them. |
| `include_bank_config` | `true` | Copy the bank's config overrides, directives and webhooks. |
| `include_history` | `false` | Copy `audit_log` and `llm_requests`. |

The clone is independent from the moment it is made: later retains, consolidation and edits on either bank leave the other alone.

The operation is recorded against the **source** bank, because the target does not exist yet — poll the source's operations endpoint for status and the per-component counts.

:::warning A clone inherits the source's webhooks
Webhooks are part of a bank's configuration, so a clone made with the default flags will call the source's webhook endpoints. Pass `include_bank_config=false`, or delete them on the clone, when they point at a per-bank consumer.
:::

### Document export & import (superseded)

The endpoints below still work exactly as documented and are unchanged; new integrations should use `/transfer/export` and `/transfer/import` above, which carry the same document archives plus the bank's own configuration.

Move documents — and the facts already extracted from them — between banks **without re-running the LLM**. Useful for testing a different embedding model, or copying data between banks/instances without paying for re-extraction. The archive carries documents, raw chunks, and extracted facts (entities by canonical name, causal links) — but **no embeddings or database ids**. On import, facts are re-embedded with the *target* bank's model and entities/links are recomputed against it, so imported documents are integrated with whatever already exists there.

### Export documents

`POST /v1/default/banks/{bank_id}/document-transfer/export` — runs as a **background operation** (a whole-bank export loads every unit and compresses a large archive, which on a big bank could exhaust memory and pin a connection). It returns `202` with an `operation_id`; poll the bank's operations endpoint, then download the archive from the `download_url` in `result_metadata`.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="document-export" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="document-export" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="document-export" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="document-export" language="go" />
</TabItem>
</Tabs>

| Query param | Description |
|-------------|-------------|
| `document_id` | Repeatable. Export only these documents; omit for the whole bank. |
| `include_observations` | Also export consolidated observations (default `false`). Only valid for a **whole-bank** export — combining it with `document_id` returns `400`. |

:::note The synchronous `GET …/document-transfer` was removed
It loaded the entire bank into memory and held a database connection for the full request, which could take down the shared API on large banks. It now returns `410` pointing here. Use the async flow above. The download route (`GET /v1/default/files/download/{key}`) authorizes the caller against the bank the archive belongs to.
:::

The archive lives as long as its export **operation record** — indefinitely by default, or until the operation is pruned when `HINDSIGHT_API_OPERATION_RETENTION_DAYS` is set (the archive is deleted in step with the row). Deleting the operation removes the archive immediately.

### Import documents

`POST /v1/default/banks/{bank_id}/document-transfer` — multipart upload (`file` = the ZIP). Runs as a **background operation** (re-embedding + entity resolution can take a while), so it returns `202` with an `operation_id`; poll the bank's operations endpoint for status and the result counts in `result_metadata`.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={memoryBanksPy} section="document-import" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="document-import" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="document-import" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="document-import" language="go" />
</TabItem>
</Tabs>

`on_conflict` controls what happens when a document id already exists in the target bank:

| Mode | Behavior |
|------|----------|
| `skip` (default) | Leave the existing document untouched. |
| `replace` | Delete the existing document's data and re-import. |
| `new-id` | Import a copy under a freshly generated id. |

### Observations

Consolidated observations are excluded by default — the target bank regenerates them from the imported facts during consolidation. Pass `include_observations=true` to carry them instead: they're restored with no LLM, their source references remapped to the imported facts (which are marked consolidated so the target won't re-consolidate them).

Because an observation can be derived from facts spanning several documents, `include_observations` is only supported on a **whole-bank export** (omit `document_id`); combining it with a document subset returns `400`.

:::warning Imported observations are inserted as-is — no merge
They are not merged or deduplicated against observations already in the target bank (consolidation merges related observations; import does not). Prefer importing observations into a fresh/empty bank, or omit `include_observations` and let the target consolidate the imported facts itself.
:::

### Enabling / disabling

Both endpoints are gated by server-level flags (default `true`). A disabled endpoint returns `404`, and `/version` reports the state under `features.document_export_api` / `features.document_import_api` (the control plane hides the buttons accordingly).

| Variable | Gates |
|----------|-------|
| `HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API` | `POST …/document-transfer/export` and `GET …/files/download/{key}` |
| `HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API` | `POST …/document-transfer` |

## Splitting a bank in place

One bank has become two concerns — two teams, two tenants, two products — and you want
the memories to follow the split. This runbook moves a **subset of documents** from one
bank to another **on the same instance**, with no LLM re-extraction and no re-chunking:
facts are re-embedded with the target bank's model and entities are re-resolved against
it, but nothing is re-derived from text by a model.

To move a whole bank to an instance with a different embedding model, vector extension or
text-search backend, you want
[Migrating a bank to a new instance](#migrating-a-bank-to-a-new-instance) instead.

:::note The Admin CLI is not the tool for this
`hindsight-admin export-bank` / `import-bank` work on a **whole bank**, and import is
not a merge — it fails if the target bank already exists. That is why the blue-green
runbook does not adapt to a split. Use the
[`/transfer/export` and `/transfer/import`](#bank-transfer-export--import) endpoints below.
:::

### What a document subset carries

A subset export is narrower than a whole-bank archive, and the difference decides three
of the steps below.

| | Travels with a document subset |
|---|---|
| Documents, raw chunks | Yes |
| Extracted facts, entity names, causal links | Yes |
| Consolidated observations | **No** — the target re-consolidates from the imported facts |
| Mental models, knowledge pages | **No** — recreate them on the target |
| Bank config, directives, webhooks | **No** — passing `include_bank_config` with `document_id` is a `400` |
| `audit_log`, `llm_requests` | **No** — passing `include_history` with `document_id` is a `400` |

Observations are the reason, not an oversight: an observation can be consolidated from
facts spanning several documents, including documents outside your subset. Carrying it
would mean silently dropping every cross-document observation, so the export refuses the
combination instead. Each fact's consolidation lifecycle is dropped along with it, which
is what lets the target consolidate the subset from scratch.

### The runbook

Set `HINDSIGHT_URL` and `API_KEY` first. `SOURCE` is the bank you are splitting and
`TARGET` is the new one.

**1. Decide the cut and pin it to a list of document ids.**

In 1.0 a document set is addressed by id, tag or time, so the cut has to be expressible
one of those ways:

```bash
curl -s -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$SOURCE/documents?tags=project-x&tags_match=all_strict&limit=1000" \
  | tee /tmp/listing.json | jq -r '.items[].id' > move.txt
wc -l move.txt
jq .total /tmp/listing.json
```

The response is paginated (`items`, `total`, `limit`, `offset`), so check `total` against
the line count and page with `offset=` if the cut is larger than one page. `tags_match`
takes `any_strict` (default), `any`, `all_strict` or `all`, and `start_date` / `end_date`
with `time_field=created_at|updated_at` narrow the set by a half-open time window.

Save the list to a file and work from it for the rest of the runbook. Every later step
reads the same `move.txt`, which is what makes the split auditable and repeatable after
an interruption.

:::warning Do not retag documents to mark the subset
It looks like the tidy way to label the cut, and it is the most expensive thing you can
do here. Changing a document's tags invalidates every observation derived from its
memories **and** resets `consolidated_at` on co-source memories from other documents, so
the bank re-consolidates a set whose size is the number of memories co-sourced with this
document's — on a densely co-sourced bank, many times the document's own memory count.
You would pay that once to apply the tag and again in step 7 when the documents are
deleted. A plain id list costs nothing.
:::

**2. Create the target bank with its own configuration.**

```bash
hindsight bank create "$TARGET" --mission "Memory for project X"
```

Create it rather than cloning the source: a
[clone](#clone-a-bank) copies the whole bank including its webhooks, which would point
the new bank at the old bank's consumers.

**3. Export the subset from the source.**

`document_id` is repeatable. Leave `include_bank_config` and `include_history` off — with
`document_id` they are rejected.

```bash
ARGS=$(sed 's/^/document_id=/' move.txt | paste -sd'&' -)
OPERATION_ID=$(curl -s -X POST -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$SOURCE/transfer/export?$ARGS" | jq -r .operation_id)
```

The export is a background operation. Poll it, then download the archive from the
`download_url` in its `result_metadata`:

```bash
curl -s -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$SOURCE/operations/$OPERATION_ID" | jq .status

DOWNLOAD_URL=$(curl -s -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$SOURCE/operations/$OPERATION_ID" \
  | jq -r .result_metadata.download_url)
curl -sf -H "Authorization: Bearer $API_KEY" "$HINDSIGHT_URL$DOWNLOAD_URL" -o split.zip
```

Read-only on the source: nothing has changed yet.

**4. Merge the archive into the target.**

```bash
curl --fail-with-body -H "Authorization: Bearer $API_KEY" -F "file=@split.zip" \
  "$HINDSIGHT_URL/v1/default/banks/$TARGET/transfer/import?mode=merge&document_conflict=skip"
```

`mode=merge` folds the archive's documents into the bank in the URL. `document_conflict`
decides what happens to ids that already exist — `skip` (default), `replace`, or `new-id`.
In merge mode `target_bank_id` and the include flags are rejected with a `400`: a merge
takes the archive's documents and nothing else.

Poll the **target** bank's operations endpoint for status and the per-component counts in
`result_metadata`.

**5. Verify the target before you delete anything from the source.**

This is the rollback point, and the only one. The source is still whole; if anything looks
wrong, delete the target bank and start again.

```bash
curl -s -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$TARGET/documents?limit=1000" | jq '{total, ids: [.items[].id]}'
```

Check `total` against `wc -l move.txt`, confirm the ids are the ones you asked for, then
run two or three recall queries whose answers you already know.

**6. Rebuild what the subset did not carry.**

Observations first. If the target bank has `enable_auto_consolidation` on, the imported
facts are queued already and you only need to wait. Otherwise trigger it:

```bash
curl --fail-with-body -X POST -H "Authorization: Bearer $API_KEY" \
  "$HINDSIGHT_URL/v1/default/banks/$TARGET/consolidate"
```

Until this finishes the target has facts and no observation layer. Recall still answers —
which is exactly why this step gets skipped — but it answers from raw facts only, so the
bank looks shallower than the source for no apparent reason.

Then recreate the bank-level synthesis by hand: mental models, their directives, and the
knowledge-page tree do not travel with a subset.

**7. Purge the moved documents from the source.**

```bash
while read -r DOC; do
  curl --fail-with-body -X DELETE -H "Authorization: Bearer $API_KEY" \
    "$HINDSIGHT_URL/v1/default/banks/$SOURCE/documents/$DOC"
done < move.txt
```

Deleting a document removes its memory units and their links, deletes the observations
that referenced those memories, and resets `consolidated_at` on co-source memories from
other documents — so the source requeues the surviving sources itself rather than
stranding them. This is not undoable: step 5 was the rollback point.

**8. Let the source settle.**

The requeued co-sources re-consolidate automatically when the source bank has
auto-consolidation on, or on the next `POST /banks/$SOURCE/consolidate`. Afterwards,
re-check the source's mental models: their `based_on` evidence cited facts that have now
moved, so a refresh is worth running before anyone reads them again.

### Verifying the split was clean

| Check | How |
|---|---|
| Every intended document arrived | the target listing's `total` matches `wc -l move.txt` |
| Nothing unintended arrived | every `.items[].id` on the target appears in `move.txt` |
| The target has an observation layer | Observations exist for the imported facts, not just memories |
| The source no longer holds the subset | Each id in `move.txt` returns `404` on the source |
| The source's observations are coherent | No memories left with `consolidated_at` reset and no queue running |
| Mental models were recreated | The target's models exist and cite the target's facts |

### Common wrong turns

- **Retagging to mark the subset** (step 1) — pays the co-source re-consolidation cost
  twice for a label an id list gives you free.
- **Passing `include_bank_config=true` with `document_id`** — a `400`, not a bank copy. A
  subset is not a bank.
- **Expecting observations in the archive** — `include_observations` is whole-bank only,
  by design. The target consolidates.
- **Deleting from the source before verifying the target** — step 5 is the rollback point
  and step 7 is irreversible.
- **Treating the import's completion as the end** — an imported bank with no consolidation
  run is facts without observations, and it looks fine.
- **Cloning the source as the target** — carries the source's webhooks, which then fire
  for the new bank.

## Migrating a bank to a new instance

To move a bank to an instance configured with a different **embedding model**, **vector extension**, or **text-search backend** — which can't be changed in place on a populated bank — export the whole bank and import it into the new instance, where every embedding and index is re-derived from the stored text with **no LLM re-extraction**. This carries documents, facts, observations, bank config, mental models, directives, and webhooks (never embeddings).

Use the `hindsight-admin export-bank` / `import-bank` commands and follow the blue-green runbook in **[Admin CLI → Migrating a bank to a new instance](../admin-cli.md#migrating-a-bank-to-a-new-instance)**.

---

### Hindsight Docs Docs Developer Api Mental Models (`hindsight-docs/docs/developer/api/mental-models.mdx`)

---
sidebar_position: 4
---

# Mental Models

User-curated summaries that provide high-quality, pre-computed answers for common queries.

See [Mental Models](../mental-models) for the concepts behind this API.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import mentalModelsPy from '!!raw-loader!@site/examples/api/mental-models.py';
import mentalModelsMjs from '!!raw-loader!@site/examples/api/mental-models.mjs';
import mentalModelsSh from '!!raw-loader!@site/examples/api/mental-models.sh';
import mentalModelsGo from '!!raw-loader!@site/examples/api/mental-models.go';
import Figure from '@site/src/components/Figure';
import mentalModelsApi from '@site/figures/mental-models-api.json';
import refreshModes from '@site/figures/refresh-modes.json';

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

<Figure doc={mentalModelsApi} />

### 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:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="create-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model" language="go" />
</TabItem>
</Tabs>

### 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 |
| `id` | string | No | Custom ID for the mental model (alphanumeric lowercase with hyphens). Auto-generated if omitted. |
| `tags` | list | No | Tags that scope the model during reflect **and** filter source memories during refresh. Defaults to `all_strict` matching, so only memories carrying every listed tag are read. See [Tags and Visibility](#tags-and-visibility). |
| `max_tokens` | int | No | Maximum tokens for the mental model content |
| `trigger` | object | No | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |

---

## Create with Custom ID

Assign a stable, human-readable ID to a mental model so you can retrieve or update it by name instead of relying on the auto-generated UUID:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-id" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-id" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-id" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-id" language="go" />
</TabItem>
</Tabs>

:::tip
Custom IDs must be lowercase alphanumeric and may contain hyphens (e.g. `team-policies`, `q4-status`). If a mental model with that ID already exists, the request is rejected.
:::

---

## 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 |
|---------|------|---------|-------------|
| `mode` | `"full"` \| `"delta"` | `"full"` | Refresh strategy. See [Refresh Mode](#refresh-mode) below. |
| `refresh_after_consolidation` | bool | false | Automatically refresh after observations consolidation |
| `refresh_cron` | string \| null | null | UTC 5-field cron expression for scheduled refreshes, such as `"0 3 * * *"` for daily at 03:00 UTC |
| `full_refresh_cron` | string \| null | null | UTC 5-field cron expression for a scheduled **full** refresh of a delta model, such as `"0 3 * * 0"` for Sundays at 03:00 UTC. Works alongside either trigger above. See [Scheduled Full Refresh](#scheduled-full-refresh). |
| `min_refresh_interval_seconds` | int \| null | null | Minimum seconds between two *automatic* refreshes of this model. See [Rate-limiting automatic refreshes](#rate-limiting-automatic-refreshes) below. `null` uses the bank/global default. |
| `tags_match` | string \| null | null | How the model's `tags` filter source memories during refresh: `any`, `all`, `any_strict`, `all_strict`, or `exact`. When `null`, a **tagged** model defaults to `all_strict` (a memory must carry every one of the model's tags). Set `"any"` to match memories carrying *any* of the tags — see [Tags and Visibility](#tags-and-visibility). |
| `tag_groups` | list \| null | null | Advanced boolean tag expressions that override flat `tags`/`tags_match` entirely. See the [Recall tags reference](./recall#tags). |
| `fact_types` | list \| null | null | Restrict which fact types the refresh reads: any of `world`, `experience`, `observation`. `null` means all three. Must not be an empty list. |
| `exclude_mental_models` | bool | false | Hide *all* other mental models from the refresh, so the model never synthesises from sibling models. |
| `exclude_mental_model_ids` | list \| null | null | Hide specific mental models by ID from the refresh. The model being refreshed is always excluded from itself. |
| `include_chunks` | bool \| null | null | Override whether the refresh's internal recall returns raw chunk text. `null` uses the bank/global `recall_include_chunks` default. |
| `recall_max_tokens` | int \| null | null | Override the token budget for facts retrieved during refresh. `null` uses the bank/global default. |
| `recall_chunks_max_tokens` | int \| null | null | Override the token budget for raw chunks retrieved during refresh. `null` uses the bank/global default. |
| `reflect_search_observations_max_tokens` | int \| null | null | Override the token budget for the refresh's `search_observations` calls. A smaller budget drops the lowest-ranked observations and shrinks the reflect context. `null` uses the shipped 5000. A refresh does not read the bank's `reflect_default_options`. |
| `reflect_search_observations_include_entities` | bool \| null | null | Override whether `search_observations` attaches resolved entity names, which can be more than half the tool payload. `null` means enabled. A refresh does not read the bank's `reflect_default_options`. |
| `budget` | string \| null | null | How many agent steps a refresh may spend, as a multiple of the server's `reflect_max_iterations`: `low` halves it, `mid` keeps it, `high` doubles it. `null` means `mid` — a refresh writes a whole document, so it does not inherit the `low` an ad-hoc reflect defaults to. |
| `response_schema` | object \| null | null | JSON Schema for structured output. When set, each refresh also stores a `structured_output` alongside the markdown content. See [Structured Output](#structured-output) below. |
| `keep_trace` | bool | false | Record how each refresh reached its result under `reflect_response.trace`. See [Troubleshoot a Refresh](#troubleshoot-a-refresh). |

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.

When `refresh_cron` is set, Hindsight checks the schedule on the server's mental-model refresh tick and refreshes the model only if memories in its scope have changed since the last refresh. `refresh_cron` and `refresh_after_consolidation` are mutually exclusive, so a model refreshes either after consolidation or on a fixed UTC schedule, not both.

`last_refresh_failed_at` on the model (and on a page in the knowledge tree) carries when that happened, so a list view can show which models have stopped refreshing themselves without reading each one's history.

**A failed refresh pauses the automatic ones.** A failed refresh is retried by the worker (`HINDSIGHT_API_WORKER_MAX_RETRIES`, 3 by default) and then stops. None of `refresh_after_consolidation`, `refresh_cron` or `full_refresh_cron` queues that model again until a refresh succeeds, so a refresh that cannot work (a prompt too large for the model, an empty account, a delta that will not apply) costs a few attempts instead of an LLM bill every tick. The failure shows in the model's [history](#history). Fix the cause and refresh the model yourself: a successful refresh resumes the automatic ones. A refresh cut off by `HINDSIGHT_API_REFLECT_WALL_TIMEOUT`, which bounds a whole refresh the same way it bounds a reflect, counts as a failure too.

### Rate-limiting automatic refreshes

A refresh is a full reflect run: retrieval plus an agentic LLM loop. With
`refresh_after_consolidation` on a bank that ingests continuously, every small retain can
therefore pay for a rebuild of every model whose scope it touched — and a handful of models
on a chatty bank adds up fast.

`min_refresh_interval_seconds` puts a floor on how often that may happen:

```json
{
  "trigger": {
    "refresh_after_consolidation": true,
    "min_refresh_interval_seconds": 1800
  }
}
```

A trigger that fires inside the window is **not dropped**. Its refresh is queued and parked
until the window closes, and every further trigger in the meantime folds into that same
queued refresh — so a burst of twenty retains costs one refresh, and that refresh still
reads everything the twenty retains added. The only thing you trade away is immediacy: the
model can lag its memories by up to the interval.

The floor applies to every automatic trigger (`refresh_after_consolidation`,
`refresh_cron` and `full_refresh_cron`). It never applies to a refresh you ask for — the refresh endpoint, the MCP
tool, and the control plane's refresh button all run immediately, and additionally release a
parked refresh if one is waiting.

Set it per bank (or server-wide) with `mental_model_min_refresh_interval_seconds` /
`HINDSIGHT_API_MENTAL_MODEL_MIN_REFRESH_INTERVAL_SECONDS`; the per-model value wins when both
are set, so `min_refresh_interval_seconds: 0` exempts one model that does need to stay current
from a bank-wide floor. The default everywhere is `0` — no floor.

While a refresh is parked, its operation stays `pending` with `next_retry_at` set to the moment
the window closes, so `GET /operations` shows exactly what it is waiting for.

### Structured Output

A mental model's content is always markdown. Set `trigger.response_schema` to *also* attach a machine-readable view: a [JSON Schema](https://json-schema.org/) **object** with a non-empty `properties` map (nested objects and arrays are supported). Each refresh then stores a `structured_output` on the model's `reflect_response`, next to the markdown — you get both the prose and a typed object.

```json
{
  "trigger": {
    "response_schema": {
      "type": "object",
      "properties": {
        "risk_level": { "type": "string" },
        "open_questions": { "type": "array", "items": { "type": "string" } }
      },
      "required": ["risk_level"]
    }
  }
}
```

Key behaviours:

- **Extracted from the final document.** The structured output is parsed from the content that is actually stored — so in `delta` mode it reflects the whole merged document, not just the facts that changed in that refresh.
- **Fails loudly.** If a `response_schema` is configured but the structured extraction cannot be produced, the refresh **fails** rather than silently persisting content with no structured view. The model's previous content and `structured_output` are preserved, and the refresh can be retried.
- **Invalid schemas are rejected** at request time: the schema must be an object with at least one property, and each property's `type` must be one of `string`, `number`, `integer`, `boolean`, `array`, or `object`.

See [Reflect → `response_schema`](./reflect#response_schema) for how the same schema works on the `reflect` endpoint. The `structured_output` value appears on the model's `reflect_response` (see [Response Fields](#response-fields)).

### Staleness Gating

Both automatic triggers run the same check before spending an LLM call: **is there a memory in this model's resolved scope newer than its last refresh?** The model's `tags`/`tags_match`, `tag_groups`, and `fact_types` all apply to that check, so activity elsewhere in the bank does not trigger a rebuild, and a cron tick over an unchanged scope is skipped entirely. Memories still waiting to be consolidated count — they are already stored, so a model whose scope reaches them is considered stale.

**Untagged writes never make a tagged model stale.** Under `tags_match: "any"` or `"all"` a refresh also *reads* untagged memories, but staleness only counts writes that carry the model's tags. Otherwise, on a bank that mixes tagged and untagged writes, every such model would read stale after nearly every write (#4857). The same goes for `tag_groups` leaves using those modes; a group that selects untagged memories explicitly (an `exact` leaf with no tags, or a `not`) still counts them. A model with no tags has the whole bank as its scope, so any write marks it stale.

Every `is_stale` you can read is computed by that same rule: the flag on a single mental-model read, the one on the list, the one surfaced to the reflect agent, and the per-page flag on the [knowledge-base tree](./knowledge-pages). A model flagged stale is one a refresh would actually rewrite.

Listing does not cost one query per model — the whole page is answered together — so you do not have to approximate. If you are already holding `last_memory_write_at` from the bank stats endpoint, a model whose `last_memory_seen_at` is at or after it is provably up to date without asking at all: nothing in the bank changed, so nothing in that model's scope did.

**Deletions are invisible to it.** Staleness asks whether anything in scope has been *written* since the model last read the memories. Deleting an in-scope memory leaves no write behind, so it does not raise the flag, and a document that cites the deleted fact keeps reporting itself up to date until something in its scope is written or you refresh it yourself.

**Two timestamps answer two different questions.** `last_refreshed_at` is wall-clock: when a refresh last finished. It advances on every refresh that completes — including one that read the scope, found nothing new, and left the document alone — so a client driving refreshes itself can use it to tell "I already did this one" from "this one still needs a call". `last_memory_seen_at` is a position in the data: the newest in-scope memory the last refresh saw. It stands still while the model's scope is quiet, however many times you refresh, which is what makes it the right side of the staleness comparison. A model with a recent `last_refreshed_at` and an old `last_memory_seen_at` is not a contradiction — it means you refreshed a document that had nothing new to say.

### What a Refresh Reads

- **A point-in-time snapshot.** Each refresh is bounded by a database-time cutoff and only reads memories committed at or before it, so a write landing mid-refresh is never half-included. The watermark recorded afterwards is the newest in-scope memory the refresh actually saw — not `now()` — so a write that commits just after the snapshot stays "newer" and is picked up on the next round instead of being skipped.
- **Only what's new, in `delta` mode.** A delta refresh scopes its retrieval to memories created since the last refresh, so the LLM reads genuinely new information rather than re-reading the whole bank.
- **Cumulative grounding.** Even in delta mode, `reflect_response.based_on` accumulates: the facts from this refresh are merged with everything previous refreshes relied on, deduplicated by id. The document rests on all of them, not just the latest slice.
- **Nothing, if nothing is relevant.** A refresh that finds no topic-relevant memories leaves the content alone and only advances the watermark, so the same empty window stops re-triggering.
- **No LLM call at all, if there is nothing to read.** Before the agentic loop starts, a refresh checks whether anything is in reach under this model's own settings: an in-scope memory (`tags`/`tags_match`, `tag_groups` and `fact_types` all narrow it) inside the window it would read — the whole bank in `full` mode, everything since `last_memory_seen_at` in `delta` mode — or a sibling mental model this refresh could read (same tags, not in `exclude_mental_model_ids`), unless `exclude_mental_models` closed that door. Sibling models alone only count for a document that has nothing to lose — an empty one, or one no refresh has ever written with memories in scope: a document built from memories is never rewritten from sibling models once its own memory scope comes up empty. When there is nothing, the refresh completes immediately, leaves the document untouched, and spends nothing. This is the common case on a bank that was just created: its pages are refreshed the moment they exist, before anything has been retained.
- **A failed retrieval is not an empty one.** If a retrieval step raises — the database, the embedder or the reranker is unavailable — the refresh **fails** instead of writing what the model came up with without it. Content, `structured_output` and the watermark are all left untouched, so the retry reads the same window again. This is the distinction that matters: "we looked and there is nothing on this topic" is an answer and gets written; "we could not look" is a failure and gets preserved. Without it, a broken retriever silently replaced a document built over months with a generic "I don't have information about that".

### Refresh Mode

Two strategies are available for how a refresh produces the new content:

- **`full`** *(default)* — every refresh regenerates the entire content from scratch. Simple and predictable: the LLM synthesises a fresh document from the retrieved memories. Best when the document is short, when you want every refresh to potentially restructure the output, or when you're not yet sure what the final shape should be.

- **`delta`** — refresh emits a list of typed *operations* (add a section, append a bullet, replace a block, remove a stale paragraph) against the document's existing structure, then renders the result. Sections that aren't targeted by any operation are copied through **byte-identical** — no paraphrasing, no whitespace drift, no list-style normalisation. Best for long-lived "playbook"–style mental models where you want stability across refreshes and only the genuinely changed parts to move.

<Figure doc={refreshModes} />

#### How delta mode works

Hindsight keeps an authoritative **structured** representation of the document — an ordered list of sections, each holding a list of blocks, where a block is one markdown fragment (a paragraph, a list, a table, a code fence) stored exactly as written. The markdown you read is a deterministic render of that structure. Because a block is never re-interpreted, formatting a refresh didn't touch cannot be rewritten by one — a table stays a table. A delta refresh never asks the LLM to rewrite the document; it asks for operations against that structure:

| Operation | Effect |
|---|---|
| `add_section` / `remove_section` | Add or drop a whole section |
| `rename_section` | Change a section's heading |
| `replace_section_blocks` | Replace a section's contents wholesale |
| `append_block` / `insert_block` | Add a block at the end of, or after a named block in, a section |
| `replace_block` / `remove_block` | Change or drop one block |

Anything no operation mentions is copied through untouched, so unchanged prose is preserved rather than regenerated and checked. This matters because "preserve the unchanged content" is only a soft constraint on an LLM — generating the next token from a gestalt of the input is what it intrinsically does, so instructed-to-preserve prose drifts over many refreshes.

Sections and blocks are addressed by id, never by position, so an operation cannot land on the wrong one by miscounting. Failure modes are conservative by design: an operation referencing a section or block that doesn't exist — or a block that lives in a different section than the one it names — is **dropped** rather than guessed at, and the rest of the operations still apply. When *every* operation points at something missing, the model is asked once more, shown the ids it got wrong and the sections the document actually has. The refresh records which ones were dropped and why, so you can see that part of that round's new information didn't make it into the document.

Delta mode falls back to a full regeneration automatically in two cases:
1. The mental model has no existing content yet (nothing to anchor edits on).
2. The `source_query` has changed since the last refresh (the topic has shifted; the existing structure may no longer apply).

**A delta refresh never replaces the document with a partial one.** Because delta retrieval only reads memories newer than the last refresh, an answer written from that window covers just the recent slice of the topic — it is material for editing the document, not a replacement for it. So when the edits can't be made at all — the provider call fails, the response can't be read, or every single operation is rejected — the existing content stays exactly as it is and the refresh **fails** instead of completing. Nothing is lost, the refresh's time window is not advanced, and a retry sees the same memories again. The same holds for an empty answer: a populated document is never overwritten with an empty one. A delta refresh is never turned into a full rewrite behind your back: if a model's delta refreshes keep failing, switch its `mode` to `full`.

| Use Case | Recommended Mode | Why |
|----------|-----------------|-----|
| Skill / playbook docs | `delta` | Sections live for many refreshes; only specific rules change |
| Onboarding summaries | `delta` | Adding new team members shouldn't restructure the doc |
| Real-time dashboards | `full` | Each refresh is a fresh snapshot |
| Short FAQ summaries | `full` | Whole-document regeneration is cheap and unambiguous |

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-trigger" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-trigger" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-trigger" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-trigger" language="go" />
</TabItem>
</Tabs>

### Scheduled Full Refresh

A `delta` model keeps unchanged sections byte-for-byte, which is the point — but over months
of small edits a document can drift from what a fresh write would say: wording shaped by
increments, sections that are hard to retire. `full_refresh_cron` adds a second, independent
schedule on which the model is refreshed in **full** mode, while every other refresh stays
`delta`:

```json
{
  "trigger": {
    "mode": "delta",
    "refresh_after_consolidation": true,
    "min_refresh_interval_seconds": 21600,
    "full_refresh_cron": "0 3 * * 0"
  }
}
```

This model is edited in place after each consolidation (at most every 6 hours), and rewritten
from scratch every Sunday at 03:00 UTC. Afterwards it carries on in `delta` mode — the
schedule never changes `mode`.

- **Independent of the other triggers.** It combines with `refresh_after_consolidation` or
  with `refresh_cron`. If both crons are due on the same check, one refresh runs, in full mode.
- **Its own clock.** It comes due against `last_full_refreshed_at`, not `last_refreshed_at`,
  so frequent delta refreshes never push it back.
- **Skipped when there is nothing to fix.** If no refresh ran since the last full one, the
  tick is skipped. Unlike `refresh_cron` it is not gated on new memories: drift left by past
  edits is still there on a quiet bank.
- **Same safety as any automatic refresh.** It honours `min_refresh_interval_seconds`, pauses
  while the last refresh failed, and a full refresh that fails keeps the current content.

To run one full refresh now, send `{"mode": "full"}` to the [refresh endpoint](#refresh-a-mental-model).

### 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

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="list-mental-models" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="list-mental-models" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="list-mental-models" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="list-mental-models" language="go" />
</TabItem>
</Tabs>

A [knowledge page](./knowledge-pages) keeps its content in a mental model, so the list includes the models behind pages too. Each item carries `is_knowledge_page` to tell them apart, and `exclude_knowledge_pages=true` lists standalone mental models only (both the items and `total`).

---

## Get a Mental Model

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="get-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="get-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="get-mental-model" language="go" />
</TabItem>
</Tabs>

### Detail Levels

Both **List** and **Get** endpoints accept an optional `detail` query parameter that controls how much data is returned. This is useful for reducing response size, especially in agent boot flows or MCP clients where context budget is limited.

| Level | Fields Returned | Use Case |
|-------|----------------|----------|
| `metadata` | `id`, `bank_id`, `name`, `tags`, `is_stale`, `last_refreshed_at`, `last_memory_seen_at`, `created_at` | Inventory — "what models exist?" |
| `content` | All metadata fields + `source_query`, `content`, `max_tokens`, `trigger` | Agent boot — "what do the models say?" |
| `full` | All fields including `reflect_response` | Deep inspection — "what evidence backs this model?" |

The two endpoints default differently:

- **List** defaults to `metadata`. Listing is an index — returning every model's synthesized content by default let one request pull a whole bank's knowledge in bulk. Content is opt-in.
- **Get** defaults to `full`. You already named the one model you want.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="mental-model-detail" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="mental-model-detail" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="mental-model-detail" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="mental-model-detail" language="go" />
</TabItem>
</Tabs>

The `detail` parameter is available on the `get_mental_model` MCP tool. The
`list_mental_models` MCP tool does not take it: it always returns metadata
(including `is_stale`), and an agent reads a specific model's content with
`get_mental_model`.

:::tip
Use `detail=content` on the List endpoint for agent orientation flows that genuinely need every model's text. It includes everything the agent needs to understand the models without the heavyweight `reflect_response` provenance chains, which can exceed 200KB for banks with many models.
:::

:::note Upgrading
The List endpoint previously defaulted to `full`. A caller that omits `detail` and reads `content`, `source_query`, `max_tokens` or `trigger` off the listed items now gets `null` — pass `detail=content` explicitly.
:::

### Response Fields

| Field | Type | Detail Level | Description |
|-------|------|-------------|-------------|
| `id` | string | metadata | Unique mental model ID |
| `bank_id` | string | metadata | Memory bank ID |
| `name` | string | metadata | Human-readable name |
| `tags` | list | metadata | Tags for filtering |
| `last_refreshed_at` | string | metadata | When a refresh last finished — wall-clock. Advances on every completed refresh, including one that found nothing new. Answers "have I already refreshed this?" |
| `last_memory_seen_at` | string | metadata | The newest in-scope memory the last refresh saw. Answers "is this behind the data?" — compare against `last_memory_write_at` from the stats endpoint |
| `last_full_refreshed_at` | string \| null | metadata | When a refresh last ran in full mode — asked for, scheduled, or a delta that fell back to full. `full_refresh_cron` comes due against this |
| `created_at` | string | metadata | When the mental model was created |
| `source_query` | string | content | The query used to generate content |
| `content` | string | content | The generated mental model text |
| `max_tokens` | int | content | Maximum tokens for the mental model content |
| `trigger` | object | content | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |
| `reflect_response` | object | full | Full reflect response including `based_on` provenance facts |

---

## Refresh a Mental Model

Re-run the source query to update the mental model with current knowledge:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="refresh-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="refresh-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="refresh-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="refresh-mental-model" language="go" />
</TabItem>
</Tabs>

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

**Full refresh on demand.** Send `{"mode": "full"}` as the body to rewrite the content from
scratch this once, whatever the model's `mode`. The model keeps its mode — a `delta` model
goes back to editing in place on its next refresh. Use it to clear the drift a `delta` model
builds up over many edits; to do it on a schedule, set
[`full_refresh_cron`](#scheduled-full-refresh).

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="full-refresh-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="full-refresh-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="full-refresh-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="full-refresh-mental-model" language="go" />
</TabItem>
</Tabs>

**Refreshes coalesce.** A model has at most one refresh waiting at a time: if one is
already queued and has not started yet, this call returns *that* operation instead of
queueing an identical second one — the queued refresh reads the model as it stands when
it runs, so it already covers what you just asked for. Poll the returned `operation_id`
as usual. A refresh that is already *running* is not reused: it may have read the model
before your latest change, so a new operation is queued behind it. A full refresh that joins
a queued plain one turns that one into a full refresh.

**And they run one at a time.** A model's refreshes never overlap: the operation queued
behind a running one starts when that one finishes, so the model is written by one refresh
at a time instead of by whichever happened to finish last. This is per model — a bank
refreshes as many different models in parallel as it has workers for.

---

## Troubleshoot a Refresh

When a refresh produces a document you didn't expect — nothing changed, the wrong
things changed, or delta edits didn't apply — the stored content alone doesn't say
why. Two tools report the reasoning behind a refresh.

### Dry run: preview a refresh before it happens

`POST /mental-models/{id}/dry-run-refresh` runs the real pipeline and reports what a
refresh *would* do. Nothing is written: not the content, structured document,
watermark, nor `last_refreshed_at`. Because nothing is persisted, a delta dry run
reads exactly the window the next real refresh will, and repeating it reads that same
window again.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="dry-run-refresh" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="dry-run-refresh" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="dry-run-refresh" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="dry-run-refresh" language="go" />
</TabItem>
</Tabs>

The response answers the questions the stored document can't:

| Field | What it tells you |
|-------|-------------------|
| `requested_mode` / `effective_mode` | Whether the refresh ran in the mode you configured |
| `mode_fallback_reason` | Why the delta edits weren't applied: `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, or `delta_ops_all_skipped` (every operation was rejected) |
| `scope` | The tags, match mode, and fact types that actually filtered memories — not the ones stored on the model |
| `window` | The `created_after`/`created_before` bounds read, and the watermark that would be persisted |
| `facts.retrieved` vs `facts.used` | How much retrieval returned versus how much the reflect agent judged relevant |
| `delta_operations` | The operations emitted, applied and skipped |
| `diff` | A unified diff from the stored content to the content it would write |
| `outcome` / `would_persist` | Whether a real refresh would write, keep the content, or fail |
| `warnings` | Conditions worth attention, in plain language |

The dry run takes no parameters, on purpose. It is the production refresh pipeline
with exactly two writes skipped — the content (and with it the structured document
and history entry) and the watermark that moves `last_refreshed_at`. Nothing about
how it runs can be configured, because a dry run you can configure stops predicting
the refresh it exists to predict. To preview a different configuration, change the
model with `PATCH` and run it again.

:::warning
A dry run runs the same LLM calls a real refresh does, so it costs the same tokens
and takes the same time. It is validated and billed like a refresh.
:::

### Keep traces: record every refresh as it happens

A dry run only explains a refresh you run yourself. Refreshes driven by
`refresh_after_consolidation`, `refresh_cron` or `full_refresh_cron` happen with nobody watching, and by
the time you notice a bad document, the run that produced it is gone.

Setting `trigger.keep_trace` records the same reasoning on every refresh of that
model — scheduled ones included — under `reflect_response.trace`:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="keep-trace" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="keep-trace" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="keep-trace" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="keep-trace" language="go" />
</TabItem>
</Tabs>

Only the latest refresh's trace is kept, and it is recorded even when a refresh
fails — which is when it matters most, since a failed refresh leaves nothing else
behind to inspect.

#### What a trace contains

The trace is deliberately shaped like a [reflect](./reflect) trace: the calls the
agent made, plus the decision specific to a refresh. It is stored on the mental
model row and re-read on every fetch, so it holds nothing that can be derived from
somewhere else.

| Field | Contents |
|-------|----------|
| `effective_mode` | Whether the run ended up `full` or `delta` |
| `mode_fallback_reason` | Why delta was requested but not applied — `no_baseline_content`, `source_query_changed`, `structured_doc_unreadable`, `delta_ops_failed`, `delta_ops_all_skipped` |
| `outcome` | `content_written`, `content_preserved_no_new_facts`, `refresh_failed_empty_candidate`, or `refresh_failed_delta_not_applied` (the edits didn't apply, so the document was kept and the refresh failed). The operation record adds two the executor cannot produce, because they happen outside a run: `refresh_failed_structured_output` and `refresh_failed_error` (a retrieval tool raised, the agent produced no answer, or something unforeseen escaped the refresh) |
| `tool_calls[]` | Per call: `tool`, the agent's `reason`, the full `input`, `result_count`, `duration_ms`, and the `iteration` it belongs to |
| `llm_calls[]` | Per call: `scope` (`agent_1`, `agent_2`, …, `final`) and `duration_ms` |
| `delta_operations` | The operations emitted in delta mode, `applied` and `skipped` |
| `usage` | Token counts across the run's LLM calls |
| `recorded_at`, `duration_ms`, `warnings[]` | When it ran, how long it took, and anything worth a human's attention |

#### What a trace deliberately omits

- **Tool outputs.** Only `result_count` is kept. Recall payloads are large and the
  trace is re-read on every model fetch, so storing them would grow the row without
  bound. A count is enough to see *that* a tool came back empty; when you need the
  raw prompts and responses, use [LLM request tracing](../monitoring), which stores
  them separately and expires them on its own retention schedule.
- **The evidence.** The facts a refresh grounded the document on already live in
  `reflect_response.based_on`, in the same shape reflect uses. The trace does not
  duplicate them.
- **The resolved scope and time window.** These describe what the model's current
  configuration reads rather than what a past run did, so they are returned by the
  dry run instead of being written on every refresh.

This keeps a stored trace small: a few kilobytes for a typical run, dominated by the
tool inputs.

---

## Clear a Mental Model

Clear a mental model's content so the next refresh performs a **full re-synthesis** from scratch, regardless of the model's trigger mode.

This is useful for delta-mode models that have accumulated drift over many incremental refreshes. Over time, small inaccuracies can compound as each delta refresh only sees new facts since the last. Clearing and then refreshing produces a clean baseline from all facts.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="clear-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="clear-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="clear-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="clear-mental-model" language="go" />
</TabItem>
</Tabs>

The clear operation is synchronous and resets the content to an empty string. The model's configuration (name, source query, trigger settings) is preserved. Since the content is now empty, the next `/refresh` call will always perform a full regeneration — even if the model's trigger mode is set to `delta`.

:::tip
To clear a delta model's drift, prefer a [full refresh](#refresh-a-mental-model) (`{"mode": "full"}`), or set
[`full_refresh_cron`](#scheduled-full-refresh) to do it on a schedule. Unlike clearing first,
a full refresh that fails leaves the current content in place.
:::

---

## Update a Mental Model

Update the mental model's name:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="update-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="update-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="update-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="update-mental-model" language="go" />
</TabItem>
</Tabs>

---

## Delete a Mental Model

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="delete-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="delete-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="delete-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="delete-mental-model" language="go" />
</TabItem>
</Tabs>

---

## Tags and Visibility

Mental models support the same tag system as memories. When you assign tags to a mental model, those tags control both **which memories it reads** during refresh and **when it is surfaced** during reflect.

### How tags affect mental model refresh

:::warning
Adding tags to a mental model narrows the pool of source memories its refresh can read from. If no memories carry those tags yet, refresh will return empty content (e.g. `"I cannot find any information…"`) even though direct `reflect` on the same query works. Backfill tags on the relevant memories first, or override the default via `trigger.tags_match` / `trigger.tag_groups`.
:::

When a mental model is refreshed (manually or automatically), it runs an internal reflect call to regenerate its content. If the mental model has tags, that reflect call uses `all_strict` tag matching — meaning it will only read memories that carry **all** of the mental model's tags. Untagged memories are excluded.

```
Mental model tags: ["user:alice"]

During refresh, it reads:
  ✅ "Alice prefers async communication"     — has "user:alice"
  ✅ "Team uses Slack for announcements"      — has "user:alice" (plus other tags)
  ❌ "Company policy: no meetings on Fridays" — untagged, excluded
  ❌ "Bob dislikes long meetings"             — no "user:alice" tag
```

This means a mental model tagged `["user:alice"]` will also pick up memories tagged `["user:alice", "team"]` — extra tags on a memory don't disqualify it. Only the mental model's own tags are required to be present.

#### Overriding the default with `tags_match`

The `all_strict` default is the safest choice for single-tag models, but it filters out everything for a **multi-tag** model whose memories only carry one tag each. If a model tagged `["projects", "mental-model"]` reads from memories tagged narrowly (`["project:status"]`, `["tooling"]`, …), no single memory carries *all* the model's tags and the refresh comes back empty.

To match memories that carry **any** of the model's tags — the same default `recall` and `reflect` use — set `trigger.tags_match` to `"any"` at creation:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model-tags-match" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-tags-match" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="create-mental-model-tags-match" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model-tags-match" language="go" />
</TabItem>
</Tabs>

The MCP `create_mental_model` tool exposes the same option as a top-level `tags_match` argument. Available modes are `any`, `all`, `any_strict`, `all_strict`, and `exact` — see the [Recall tags reference](./recall#tags) for their exact semantics.

### How tags affect mental model lookup during reflect

When you call `reflect` with tags, those same tags are used to filter which mental models the agent can see. A mental model is visible only if its tags overlap with the tags on the reflect request.

For more details on tag matching modes (`any`, `any_strict`, `all`, `all_strict`) and worked examples, see the [Recall tags reference](./recall#tags).

### Listing mental model tags

`GET /v1/default/banks/{bank_id}/tags` accepts a `source` query parameter that selects which tag space to enumerate:

- `source=memories` *(default)* — tags attached to memory units.
- `source=mental_models` — tags attached to mental models in this bank.

Use the `mental_models` source to populate autocomplete or filter UIs over mental-model tags, distinct from the (typically larger) memory tag set.

---

## History

Every time a mental model's content changes (via refresh or manual update), the previous version is saved with a timestamp. You can retrieve the full change log with the history endpoint:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="get-mental-model-history" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="get-mental-model-history" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="get-mental-model-history" language="go" />
</TabItem>
</Tabs>

### Response

The endpoint returns a list of history entries, most recent first:

| Field | Type | Description |
|-------|------|-------------|
| `previous_content` | string \| null | The content before this change (`null` if not available, and always `null` on a failure record) |
| `changed_at` | string | ISO 8601 timestamp of when the change occurred |
| `kind` | `"refresh_failed"` \| absent | Present only on a **failure record** — a refresh that refused to write. Absent on version snapshots |
| `outcome` | string \| absent | On a failure record: the operation outcome, e.g. `refresh_failed_error` |
| `failure_reason` | string \| absent | On a failure record: why it refused — `retrieval_failed`, `no_answer`, `unexpected_error`, `empty_candidate`, `structured_doc_unreadable`, `delta_ops_failed`, `delta_ops_all_skipped`, `delta_not_applied`, `structured_output_failed` |
| `error_message` | string \| absent | On a failure record: the exception, as the operation reports it |

Each version entry captures the **content before the change** and when it happened. The current content is returned by the standard [Get a Mental Model](#get-a-mental-model) endpoint.

**Failures are recorded too.** A refresh that refuses to write produces no version, so before this it left no trace on the model at all — a document whose refreshes had been failing for a week was indistinguishable from one nobody had refreshed, and its stored `reflect_response` still described the last run that *succeeded*. Those runs now append a failure record carrying the reason and the exception. Read them by their `kind`: a client that only wants versions filters `kind` out, and one that wants to know whether the model is current checks whether the newest entry is a failure.

Retention is applied per kind, so a run of failures cannot evict the version history — each is capped at `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` independently. A retried refresh records one entry per attempt.

:::note
History tracking is enabled by default. Set `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY=false` to disable it, and `HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES` to change how many versions are kept per model (default `50`).
:::

---

## 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

---

### Hindsight Docs Docs Developer Api Operations (`hindsight-docs/docs/developer/api/operations.mdx`)

---
sidebar_position: 9
---

# Operations

Hindsight runs several maintenance and ingestion tasks asynchronously instead of blocking the API call that triggers them. These tasks share a single queue (`async_operations`) and a single worker pool, and the same REST endpoints — list, status, cancel, retry — work across every type.

This page explains each operation type, when it fires, and how to inspect or manage it.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import operationsPy from '!!raw-loader!@site/examples/api/operations.py';
import operationsMjs from '!!raw-loader!@site/examples/api/operations.mjs';
import operationsSh from '!!raw-loader!@site/examples/api/operations.sh';
import operationsGo from '!!raw-loader!@site/examples/api/operations.go';

:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
:::

## How operations work

When an API call needs background work, the request handler writes a row to the `async_operations` table with `status=pending` and returns immediately. A worker (running either in-process inside the API by default, or as a dedicated service — see [Services - Worker Service](../services#worker-service)) polls the table, claims pending rows, executes the corresponding handler, and marks the row `completed` or `failed`.

By default, every operation runs in-process: no external queue, no extra process to deploy. The same code paths support scaling out to dedicated worker processes when throughput demands it.

### Lifecycle

| Status | Meaning |
|--------|---------|
| `pending` | The row is queued. Either no worker has picked it up yet, or it is parked via `next_retry_at` in the future (e.g., backpressure, or an LLM provider's reported quota reset). `POST /…/retry` on a parked operation clears `next_retry_at` so it runs now. |
| `processing` | A worker has claimed the row and is actively running the handler. |
| `completed` | The handler returned successfully. |
| `failed` | The handler raised. `error_message` carries the reason; you can re-queue with `POST /…/retry`. |
| `cancelled` | The operation was cancelled via `DELETE /…/operations/{id}`. Works on `pending` and `processing` operations alike. A consolidation also ends here when it hits a temporary error while another consolidation is already waiting for the same bank: that one does the same work, so this one is not retried. Its `result_metadata.superseded_by` names the waiting operation, and `error_message` keeps the error it hit. |

The worker retries failed operations up to `HINDSIGHT_API_WORKER_MAX_RETRIES` times before settling on `failed`. Deterministic failures (e.g., invalid embedding dimensions, integrity violations, a provider content-policy refusal, or the LLM rejecting the configured key with HTTP 401/403) skip retries — they won't succeed by re-running. Fix the cause, then retry the operation.

Completed, failed, and cancelled operations are kept indefinitely by default. Set `HINDSIGHT_API_OPERATION_RETENTION_DAYS` to a positive number of days to bound that history: the background maintenance loop then prunes expired terminal rows in bounded batches, on its own schedule rather than as a side effect of task processing. PostgreSQL only — the maintenance loop does not run on Oracle, so operation history is unbounded there. The full row shares that TTL, so while an operation is retained its payload stays available — failed and cancelled operations can be retried, and completed ones inspected with `include_payload=true`. Pending and processing operations are never removed by retention cleanup.

## Operation types

Every operation has an `operation_type` in the database and a `task_type` in the payload. They're usually the same.

### `retain`

Submitted by `POST /v1/default/banks/{bank_id}/memories` with `async=true`, or by the multi-item `retain_batch` call. The handler runs the same pipeline as a synchronous retain: fact extraction (LLM), embedding generation, entity resolution, and link creation (temporal, semantic).

Use async retain when you're ingesting thousands of items and don't want the HTTP call to hold for minutes. The `operation_id` in the response lets you poll for completion.

#### Parent op: `retain_batch`

For large submissions, Hindsight automatically splits the input into sub-batches and creates a single `retain_batch` parent operation that tracks the children. The parent's status reflects the aggregate — `pending` until at least one child is running, `processing` while children execute, `completed` once every child has finished, `failed` if any child failed. Each child is itself a `retain` operation linked to the parent, so you can drill in for per-batch error messages.

When you list operations, the parent and its children all appear by default. Pass `exclude_parents=true` to hide the aggregate rows and show only individual `retain` jobs.

### `file_convert_retain`

Submitted by file upload endpoints. The handler runs MIME-specific conversion (PDF → text, DOCX → text, etc.) and then passes the extracted text into the retain pipeline. Failures here are **non-retryable** by default — a corrupted PDF or missing OCR won't improve on rerun, so the operation goes straight to `failed`.

Which parser runs (`markitdown`, `iris`, or `llama_parse`) is selected per deployment via `HINDSIGHT_API_FILE_PARSER`, and clients can override it per request — see [Configuration → File Processing](../configuration#file-processing).

### `consolidation`

Produces **observations** from new world/experience memories. See [Observations](../observations) for what they are and how they're synthesized.

Triggered automatically:

- After every retain that added world/experience facts (gated by per-bank `enable_auto_consolidation` and `enable_observations`).
- After deletes that invalidated existing observations (the source memory disappeared → derived observations are stale → re-run with the surviving co-source memories).
- Manually via `POST /v1/default/banks/{bank_id}/consolidate`. Pass `observation_scopes` to consolidate only memories matching specific tag combinations.

**Bank-deduped**: while one `consolidation` job is pending for a bank, repeat submits return the existing `operation_id` instead of stacking. Once the job starts processing, the next submit becomes the next pending slot.

### `refresh_mental_model`

A mental model has a `source_query` that defines which memories it summarizes. The handler re-runs that query, re-summarizes the result, and updates the model's content in place.

Triggered either manually via `POST /v1/default/banks/{bank_id}/mental-models/{id}/refresh`, or automatically by the auto-refresh schedule for mental models that have one configured.

### `graph_maintenance`

Reconciles derived state that goes stale after a delete. Every invocation drains two queues, both filled by the delete itself, so a run only ever looks at what that delete touched:

1. **Link top-up.** Drains the units whose outgoing temporal/semantic links lost a neighbour. For each, if the unit is under its cap (20 temporal, 50 semantic), Hindsight re-runs the same probes retain uses and inserts the missing links. Without this, the retain pipeline's top-K capping would leave surviving units permanently under-capped after every delete — degrading graph-expansion recall.
2. **Entity prune.** Drains the entities the delete may have stranded. Those with no remaining `unit_entities` reference are deleted (FK `ON DELETE CASCADE` removes their `entity_cooccurrences` rows with them); for the survivors, cooccurrence rows where both endpoints still exist but no current memory_unit references both are cleaned up — the cooccurrence was real when recorded, but every unit that witnessed it has since been deleted.

Bank-deduped at submit time, so concurrent triggers against the same bank coalesce into one drain.

Each run works in committed batches under a wall-clock budget. A backlog too large for one run — a bulk delete, say — is not an error: the run reports what it finished and the next one resumes where it stopped.

**Triggers:** any delete that removes memory_units — `DELETE /documents/{id}`, `DELETE /memories/{id}`, and re-retaining an existing `document_id` (the upsert path). A full bank wipe (`delete_bank`) is a no-op: there's nothing left in the bank to maintain.

### `webhook_delivery`

After certain operations complete (e.g., consolidation finishing on a bank with a registered webhook), Hindsight enqueues a `webhook_delivery` task. The handler POSTs the payload to the configured URL and retries on transient failures.

## Endpoints

All paths below are scoped by `bank_id`.

### List operations

```bash
GET /v1/default/banks/{bank_id}/operations
```

Query parameters:

| Param | Description |
|-------|-------------|
| `status` | Filter by `pending`, `processing`, `completed`, `failed`, `cancelled`. |
| `type` | Filter by `retain`, `file_convert_retain`, `consolidation`, `refresh_mental_model`, `graph_maintenance`, `webhook_delivery`. |
| `limit` | 1–100, default 20. |
| `offset` | Pagination offset. |
| `exclude_parents` | Exclude parent batch operations from results (large `retain_batch` calls create one parent + N children). |

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={operationsPy} section="operations-list" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={operationsMjs} section="operations-list" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={operationsSh} section="operations-list" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={operationsGo} section="operations-list" language="go" />
</TabItem>
</Tabs>

`items_count` is operation-specific — non-zero only for retain-shaped operations (it counts content items in the submission).

### Get operation status

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={operationsPy} section="operations-get" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={operationsMjs} section="operations-get" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={operationsSh} section="operations-get" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={operationsGo} section="operations-get" language="go" />
</TabItem>
</Tabs>

Query parameters:

| Param | Description |
|-------|-------------|
| `include_payload` | Include the raw task payload (the submission params) in the response as `task_payload`. Default `false`; may be large. |

A few response fields are worth calling out:

| Field | Description |
|-------|-------------|
| `updated_at` | When the operation's row last changed — claim, progress heartbeat, or completion. |
| `progress` | Last-known progress snapshot for a running operation, or `null` if none was recorded (completed-instantly or pre-feature rows). |
| `task_payload` | The raw submission params; only populated when `include_payload=true`. |

`progress` is written at coarse phase/batch boundaries (consolidation, batch retain) and lets you tell a healthy long-running job from a frozen one: if `processed` keeps advancing across polls the job is alive; identical numbers with no movement in `at` mean it's stuck. Its shape:

| Field | Description |
|-------|-------------|
| `stage` | Coarse phase the operation last reported (e.g. `processing_batch`). |
| `at` | ISO-8601 timestamp when this snapshot was written. |
| `processed` | Units of work finished so far (sub-batches, memories), when known. |
| `total` | Total units of work for the operation, when known. |
| `detail` | Operation-specific counters (e.g. `observations_created`, `round`, `items_in_sub_batch`). |

### Cancel an operation

Cancels a `pending` or `processing` operation. Returns `409` if it has already reached a
terminal state (`completed`, `failed`, `cancelled`).

The row is marked `cancelled` straight away, but cancelling running work is **cooperative
and not immediate**. A worker executing the operation notices at its next checkpoint — the
boundary between sub-batches or documents for retain, between LLM batches for consolidation —
and stops there, so whatever it had already committed stays committed and the batch in
progress may still finish. Operation types without checkpoints run to the end; the row stays
`cancelled` either way, because no worker write may overwrite that status.

This is also how you clear an operation stranded in `processing` by a worker that was killed
before it could finish: nothing is running, so the cancel takes effect immediately. Use
`POST /…/operations/{id}/retry` to re-queue the work afterwards.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={operationsPy} section="operations-cancel" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={operationsMjs} section="operations-cancel" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={operationsSh} section="operations-cancel" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={operationsGo} section="operations-cancel" language="go" />
</TabItem>
</Tabs>

### Retry a failed operation

The row's status resets to `pending` and the worker picks it up again. Returns `409` if the operation isn't in `failed` or `cancelled` state.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={operationsPy} section="operations-retry" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={operationsMjs} section="operations-retry" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={operationsSh} section="operations-retry" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={operationsGo} section="operations-retry" language="go" />
</TabItem>
</Tabs>

## Async retain example

Submit a batch asynchronously and poll until the operation completes:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={operationsPy} section="operations-async-retain" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={operationsMjs} section="operations-async-retain" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={operationsSh} section="operations-async-retain" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={operationsGo} section="operations-async-retain" language="go" />
</TabItem>
</Tabs>

## Worker tuning

Each worker has a single concurrency budget (`HINDSIGHT_API_WORKER_MAX_SLOTS`, default 10) shared across all operation types. Per-type slot reservations (`HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS`) carve out guaranteed capacity within that budget; remaining slots form a shared pool any type can use. See [Configuration → Worker Configuration](../configuration#distributed-workers) for the full table.

For most deployments the defaults are fine. Reserve slots for an operation type if you've seen it starved by a flood of another type (e.g., a long file_convert_retain blocking graph_maintenance on a deletion-heavy workload).

Slots are also rotated across banks. Each claim serves the next bank in turn — one operation — then fills the rest of the pool oldest-first from anywhere. So a bank ingesting in bulk cannot own the whole pool while another bank's single write waits behind its backlog, and it is not throttled either: when no one else is waiting it still takes every slot.

## Next Steps

- [**Documents**](./documents) — Track document sources
- [**Memory Banks**](./memory-banks) — Configure bank settings

---

### Hindsight Docs Docs Developer Api Recall (`hindsight-docs/docs/developer/api/recall.mdx`)

---
sidebar_position: 2
---

# Recall Memories

Retrieve memories from a bank using multi-strategy recall.

When you **recall**, Hindsight runs four retrieval strategies in parallel — semantic similarity, keyword (BM25), graph traversal, and temporal — then fuses and reranks the results into a single ranked list. The response contains structured facts, not raw documents.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import recallPy from '!!raw-loader!@site/examples/api/recall.py';
import recallMjs from '!!raw-loader!@site/examples/api/recall.mjs';
import recallSh from '!!raw-loader!@site/examples/api/recall.sh';
import recallGo from '!!raw-loader!@site/examples/api/recall.go';

:::info How Recall Works
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
:::

:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::

## Basic Recall

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-basic" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-basic" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-basic" language="go" />
</TabItem>
</Tabs>

---

## Parameters

### query

The natural language question or statement to search for. This is the only required field. The query drives all four retrieval strategies simultaneously: it is embedded for semantic search, tokenized for BM25 keyword search, used to seed graph traversal, and parsed for temporal expressions. After retrieval, the raw query text is also passed to the cross-encoder reranker to re-score every candidate. Queries exceeding 500 tokens are rejected.

### types

Controls which categories of memory facts are searched. Accepted values are `world` (objective facts), `experience` (events and conversations), and `observation` (deduplicated, evidence-grounded beliefs consolidated from multiple memories). When omitted, all three types are searched.

Each type runs the full four-strategy retrieval pipeline independently, so narrowing `types` reduces both the result set and query cost.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-world-only" language="python" />
<CodeSnippet code={recallPy} section="recall-experience-only" language="python" />
<CodeSnippet code={recallPy} section="recall-observations-only" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-world-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-experience-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-observations-only" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-fact-type" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-world-only" language="go" />
<CodeSnippet code={recallGo} section="recall-experience-only" language="go" />
<CodeSnippet code={recallGo} section="recall-observations-only" language="go" />
</TabItem>
</Tabs>

:::tip About Observations
Observations are deduplicated, evidence-grounded beliefs consolidated from multiple facts — preferences, recurring patterns, and durable learnings the memory bank has built up. Each observation references its supporting memories (with exact quotes), and is refined rather than overwritten when new evidence arrives. They are created and maintained automatically in the background after retain operations.
:::

### prefer_observations

Because observations are consolidated from raw facts, recalling `observation` alongside `world` and `experience` can return the same information twice — once as the raw fact and once folded into an observation. With `prefer_observations` you get the best of both: you still recall every type, but whenever an observation in the results was built from a raw fact, that raw fact is dropped so the observation supersedes it. The freed slots are backfilled with the next-best results, so you don't lose coverage.

This lets you ask for everything without choosing between "raw facts only" (no consolidation) and "observations only" (which may lag behind the latest retains while consolidation catches up). Off unless the request or the bank turns it on. When a request leaves it out, the bank's `recall_prefer_observations` config decides (env `HINDSIGHT_API_RECALL_PREFER_OBSERVATIONS`, `false` unless configured), so a deployment can turn it on for clients that never pass it, such as MCP connectors. It has no effect unless both `observation` and at least one of `world`/`experience` are included in `types`.

### budget

Controls retrieval depth and breadth. Accepted values are `low`, `mid` (default), and `high`. Use `low` for fast simple lookups, `mid` for balanced everyday queries, and `high` when you need to find indirect connections or exhaustive coverage.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-budget-levels" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-budget-levels" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-budget-levels" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-budget-levels" language="go" />
</TabItem>
</Tabs>

### max_tokens

The maximum number of tokens the returned facts can collectively occupy. Defaults to `4096`. Only the `text` field of each fact is counted toward this budget — metadata, tags, entities, and other fields are not included. After reranking, facts are included in relevance order until this budget is exhausted — so you always get the most relevant memories that fit. A fact too long for the remaining budget is skipped rather than ending the selection, so shorter facts ranked behind it still come back. Hindsight is designed for agents, which think in tokens rather than result counts: set `max_tokens` to however much of your context window you want to allocate to memories.

:::note
A query that matched something never comes back empty: if not even the top fact fits the budget, it is returned whole and over budget rather than clipped mid-sentence, because an empty result list would read as "this bank has no such memory" and a clipped fact would be a claim the memory never made. The one exception is `max_tokens=0`, which means "no facts" on purpose — it is how you ask for chunks alone.
:::

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-token-budget" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-token-budget" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-token-budget" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-token-budget" language="go" />
</TabItem>
</Tabs>

### query_timestamp

An ISO 8601 datetime representing when the query is being asked, from the user's perspective. When provided, it is used as the anchor for resolving relative temporal expressions in the query and for recency scoring — for example, if the query says "last month" and `query_timestamp` is `2023-05-30`, the temporal search window becomes approximately April 2023, and recency boosts are calculated as of May 30, 2023. Without it, the server's current time is used as the anchor. This field matters most for replaying historical conversations or building agents that need time-anchored recall.

### temporal_window

An explicit `{ "start": ..., "end": ... }` pair of ISO 8601 datetimes for the temporal part of the search. Supply it when you already know the period you mean — a date picker in your UI, or an agent that has already worked out what "last quarter" resolves to — and Hindsight uses those bounds directly instead of reading dates out of the query text.

```json
{ "query": "what did we decide about pricing", "temporal_window": { "start": "2023-04-01T00:00:00Z", "end": "2023-06-30T23:59:59Z" } }
```

**This ranks, it does not filter.** Hindsight searches several ways at once, and the window steers only the time-aware part of that search: memories dated inside it are surfaced and ranked higher, while everything else keeps being searched normally. Results dated outside the window are still returned, so this is not a way to restrict an answer to a period. Note also that the dates being compared are the *memory's own* dates — when the memory says something happened — not when it was stored.

Two smaller things worth knowing: bounds are inclusive and a naive datetime (one with no timezone) is read as UTC; and the window is ignored on banks that have time-aware search turned off. `temporal_window` replaces date extraction only — [`query_timestamp`](#query_timestamp) still anchors recency scoring, so it remains useful alongside it.

### include

An optional object controlling supplementary data returned alongside the main facts.

#### chunks

When enabled, the response includes the raw source text chunks from which each fact was extracted. Chunks are fetched before the `max_tokens` filter, so setting `max_tokens=0` returns no facts but can still return chunks. The `max_tokens` sub-option (default `8192`) controls the total chunk token budget independently of the main fact budget. This is useful when agents need surrounding context beyond the extracted fact text.

:::note
When `include_chunks` is enabled, chunks are fetched based on the top-scored reranked results before token filtering. The last chunk is truncated (not dropped) to fit exactly within the budget, and each chunk carries a `truncated` flag indicating whether it was cut.

With a tag filter (`tags`, `tags_match`, `tag_groups`), a chunk is returned only when its **document's** tags pass the filter too. A fact can match through its own tags — for example a `kind:rule` fact projected from an [entity label](./memory-banks#entity-labels) — while the document it came from does not, and that document's other text must not come back with it. Such a fact is still returned; its chunk is not.
:::

#### source_facts

When enabled and `types` includes `observation`, each observation result is accompanied by the original contributing facts it was synthesized from. Source facts are returned in a top-level `source_facts` dict keyed by fact ID, and each observation result carries a `source_fact_ids` list for cross-referencing. Facts are deduplicated across observations. The `max_tokens` sub-option (default `4096`) limits the total token budget for source facts.

:::note
The budget is spent in result order, so when it runs out it is the lowest-ranked results that lose their source facts — the top results always keep theirs. `source_fact_ids` always lists every source, so an ID may have no entry in `source_facts`; the response sets `source_facts_truncated: true` when that is the budget's doing rather than a missing fact. Raise `max_tokens` (or set it to `-1`) if you need every source resolved.
:::

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-source-facts" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-source-facts" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-source-facts" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-source-facts" language="go" />
</TabItem>
</Tabs>

#### entities

Enabled by default. When active, each returned fact includes the canonical names of entities associated with it. Set to `null` to skip the entity JOIN query and reduce response size. The `max_tokens` sub-option (default `500`) is a future-facing guard for entity data.

### tags

Filters recall to memories in the requested tag scope. `tags` defaults to `null` and
`tags_match` defaults to `any`.

The `tags_match` parameter controls the filtering logic:

| Mode | Untagged memories | Match condition |
|------|-------------------|-----------------|
| `any` (default) | Included | Memory has **at least one** of the specified tags |
| `any_strict` | Excluded | Memory has **at least one** of the specified tags |
| `all` | Included | Memory has **all** of the specified tags |
| `all_strict` | Excluded | Memory has **all** of the specified tags |
| `exact` | Excluded | Memory has **exactly** the specified tag set |

The defaults and empty-filter behavior are important:

| `tags` | `tags_match` | Eligible memories |
|--------|--------------|-------------------|
| Omitted, `null`, or `[]` | Omitted (`any`) | All tagged and untagged memories |
| Omitted, `null`, or `[]` | `any`, `all`, `any_strict`, or `all_strict` | All tagged and untagged memories; an empty tag list means no filter |
| Omitted, `null`, or `[]` | `exact` | Only untagged/global memories |
| Non-empty | `any` or `all` | Matching tagged memories plus untagged/global memories |
| Non-empty | `any_strict` or `all_strict` | Matching tagged memories only |
| Non-empty | `exact` | Memories whose complete tag set exactly equals `tags` |

:::note MCP empty-scope behavior
For the MCP `recall` tool, `tags_match` is forwarded only when `tags` is present.
To select the untagged/global scope through MCP, pass both `tags: []` and
`tags_match: "exact"` rather than omitting `tags`.
:::

#### Scenario setup

Consider a bank with these four memories:

| Memory | Tags |
|--------|------|
| "Alice prefers async communication" | `["user:alice"]` |
| "Bob dislikes long meetings" | `["user:bob"]` |
| "Team uses Slack for announcements" | `["user:alice", "team"]` |
| "Company policy: no meetings on Fridays" | *(untagged)* |

#### `any` — OR matching, includes untagged (default)

Returns memories that have **at least one** matching tag, plus untagged memories.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-with-tags" language="go" />
</TabItem>
</Tabs>

Use this for **shared global knowledge + user-specific** patterns, where untagged memories represent information everyone should see.

#### `any_strict` — OR matching, excludes untagged

Same as `any` but untagged memories are excluded.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-strict" language="go" />
</TabItem>
</Tabs>

Use this when memories are **fully partitioned by tags** and untagged memories should never be visible.

#### `all` — AND matching, includes untagged

Returns memories that have **every** specified tag, plus untagged memories.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-mode" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-mode" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-mode" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all-mode" language="go" />
</TabItem>
</Tabs>

Use this when memories must belong to a **specific intersection** of scopes (e.g., only memories relevant to both a user and a project), while still surfacing shared global knowledge.

#### `all_strict` — AND matching, excludes untagged

Returns memories that have **every** specified tag, and excludes untagged memories.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all" language="go" />
</TabItem>
</Tabs>

Use this for strict scope enforcement where a memory must explicitly belong to **all** specified contexts.

:::tip Extra tags are fine
A memory with tags `["user:alice", "team", "project:x"]` will still match a filter of `["user:alice", "team"]` under `all_strict` — extra tags on the memory are not a problem. The filter only requires the memory to contain **at least** the specified tags.
:::

#### `exact` — set equality, excludes untagged

Returns memories whose tag set is exactly equal to the specified tags, regardless of tag order. Unlike `all_strict`, memories with extra tags do not match.

Use this when filtering a precise observation scope returned by `GET /v1/default/banks/{bank_id}/observations/scopes`, where `["user:alice"]` should not also match observations scoped to `["user:alice", "project:x"]`.

:::tip Filter to global (untagged) observations only
The empty scope is a real scope — it's where `observation_scopes: "shared"` consolidation writes. Set `tags_match: "exact"` with **no tags** (omit `tags`, or pass `[]`) to recall **only** untagged/global memories and exclude every tagged one:

```json
{ "query": "...", "tags": [], "tags_match": "exact" }
```

With any other `tags_match` mode, absent or empty `tags` means "no tag filter" (all memories are eligible). Only under `exact` do absent/empty tags select "the global scope". This is the way to read back just the global observations after you've started using more specific scopes.
:::

### tag_groups

`tag_groups` is a list of compound boolean tag filters. The groups in the list are AND-ed together at the top level. Each group is a recursive boolean expression: a **leaf** node `{tags, match}`, or a **compound** node `{and: [...]}`, `{or: [...]}`, or `{not: ...}`.

`tag_groups` defaults to `null`. The public REST and MCP request models treat
`tag_groups` and `tags` as mutually exclusive: if both are present, the request is
rejected. Use `tag_groups` by itself for compound filtering and normally leave the
top-level `tags_match` at its default, `any`. Each `tag_groups` leaf has its own
`match` value. The exception is top-level `tags_match: "exact"`: because exact
matching gives absent flat tags a meaning, it adds a global-only flat constraint
that is AND-ed with the compound expression.

#### Leaf node

```json
{ "tags": ["step:5", "step:8"], "match": "any_strict" }
```

`match` accepts the same values as `tags_match`: `any`, `all`, `any_strict`, `all_strict`, `exact`. Defaults to `any_strict`.

#### Fuzzy leaves

A leaf may set `resolve: "fuzzy"` (default `"exact"`) to match its tags against the bank's tags by trigram similarity instead of literally, so a filter on `typsecript` still reaches memories tagged `typescript`:

```json
{ "tags": ["typsecript"], "match": "any_strict", "resolve": "fuzzy" }
```

Each tag resolves to the bank tags scoring at least 0.45, and the leaf then matches those exactly — so `resolve` composes with every `match` mode. Similarity is length-sensitive: `kubernets` reaches `kubernetes`, but a short tag has too few trigrams to survive an edit (`kakfa` does not reach `kafka`). A tag that resolves to nothing matches nothing; the filter is never dropped. A 422 is returned if the bank has more than 5000 distinct tags, or if a `resolve: "fuzzy"` leaf with `match: "exact"` expands past 32 candidate scopes.

#### Compound nodes

```json
{ "and": [ <TagGroup>, <TagGroup>, ... ] }
{ "or":  [ <TagGroup>, <TagGroup>, ... ] }
{ "not": <TagGroup> }
```

#### Examples

**Step filter AND user scope** — two top-level groups AND-ed:

```json
{
  "tag_groups": [
    { "tags": ["step:5", "step:8", "step:12"], "match": "any_strict" },
    { "tags": ["user:ep_42"], "match": "all_strict" }
  ]
}
```

**Nested OR inside AND** — user must match, plus either step OR priority:

```json
{
  "tag_groups": [
    { "tags": ["user:alice"], "match": "all_strict" },
    { "or": [
        { "tags": ["step:5"], "match": "any_strict" },
        { "tags": ["priority:high"], "match": "all_strict" }
    ]}
  ]
}
```

**Exclusion** — user must match, but archived memories are excluded:

```json
{
  "tag_groups": [
    { "tags": ["user:alice"], "match": "all_strict" },
    { "not": { "tags": ["archived"], "match": "any_strict" } }
  ]
}
```

### trace

When set to `true`, the response includes a detailed debug trace covering the query embedding, entry points, per-strategy retrieval results, RRF fusion candidates, reranked results, temporal constraints detected, and per-phase timings. Has no effect on the retrieval logic itself. Useful for understanding why specific memories were or were not returned.

### min_scores

An optional object of per-stage score floors, each compared **inclusively** (`>=`). Any field you leave unset imposes no floor; omitting `min_scores` entirely (the default) applies no score filtering at all. The four fields operate at **two different levels of the pipeline**, and the level decides what a returned result is guaranteed to satisfy:

| field | level | effect | guaranteed by every result? |
|---|---|---|---|
| `semantic` | retrieval | minimum vector similarity, pushed into the **semantic arm's** SQL — prunes weak vector matches **before** fusion (overrides the global similarity minimum for this request) | no |
| `keyword` | retrieval | minimum keyword/full-text (BM25) score, pushed into the **keyword arm's** SQL — prunes weak keyword matches before fusion | no |
| `reranker` | post-query | minimum normalized cross-encoder score, applied to the ranked results | yes |
| `final` | post-query | minimum final ranking score, applied to the ranked results | yes |

```json
{ "query": "...", "min_scores": { "reranker": 0.5 } }
```

#### Retrieval floors constrain one arm, not the result

Recall runs [four retrieval arms](#results) — semantic, keyword, graph and temporal — and a memory reaches the response if **any** of them surfaced it. `semantic` and `keyword` prune inside the arm they name, so they change *which candidates are considered*, and with them the final ordering. They are **not predicates over each returned result**:

- a result surfaced only semantically reports `"keyword": null`, whatever `min_scores.keyword` you set;
- a result surfaced only by keyword reports `"semantic": null`, whatever `min_scores.semantic` you set;
- a result reached through the graph or temporal arm reports **neither**, and is unaffected by both floors.

Setting `semantic` and `keyword` together therefore does not restrict the response to results that clear both. That is deliberate: an intersection would discard exactly the strong single-arm matches hybrid retrieval exists to find — a paraphrase with no lexical overlap in common with the query, or an exact identifier like `amber-17` that the embedding scores poorly.

#### For abstention, use `reranker` or `final`

The post-query floors are applied to every scored result after fusion and reranking, so a returned result always clears them — and a query where nothing clears them returns no results. That is the floor to reach for when you want recall to abstain on a low-confidence or nonsense query. Note they gate a *combined* signal: `final` blends RRF rank, cross-encoder relevance, recency/temporal and strategy boosts, and `reranker` depends on the cross-encoder's calibration, so neither is a drop-in equivalent of a retrieval-stage cutoff.

Because freed slots are **not** backfilled, any floor can return fewer results than the budget allows.

**Use floors with care.** The reranker's scores are reliable for *ordering* but not as *absolute* values — a clearly-relevant memory can score `~0.001` on one query and `~1.0` on another, so a fixed cutoff risks silently dropping good results. Calibrate any threshold against the scores you actually observe (recall with no `min_scores` first and inspect the [`scores`](#scores) object). See the note under [`scores`](#scores) on why the scale is relative, not absolute, before relying on a fixed threshold.

**Not available with the TypeSafe reranker.** TypeSafe ranks the whole pool at once and returns each memory's rank position, not a relevance score: the top memory is `1.0` on every query, so a `reranker` floor would only keep a fixed share of the results. Recall rejects `min_scores.reranker` with HTTP 400 when TypeSafe is the configured reranker (and skips the floor if a failover chain falls back to it). Use `final` instead.

---

## Response

### results

The main list of recalled facts, ordered by relevance. Relevance is computed by running four retrieval strategies in parallel — semantic similarity, BM25 keyword, graph traversal, and temporal — fusing their rankings with Reciprocal Rank Fusion (RRF), then re-scoring the merged candidates with a cross-encoder reranker against the original query.

Each result carries a [`scores`](#scores) object (see below). Treat these as **relative** signals: they reflect the ranking within a single query, not an absolute, cross-query confidence — a `0.8` from one query is not comparable to a `0.8` from another. For most agents the right approach is to consume memories in order and let `max_tokens` determine how many fit, rather than filtering by score. The `scores` object (and the [`min_scores`](#min_scores) parameter) exist for callers that want to inspect the ranking or drop a low-confidence tail; calibrate any threshold against the scores you see on an unfiltered query.

Each item in `results` has the following fields:

#### id

The unique identifier of this fact. Use it to cross-reference with `source_facts` or for application-level deduplication.

#### text

The extracted fact text as stored in the memory bank.

#### type

The fact category: `world` for objective information, `experience` for events and conversations, or `observation` for consolidated knowledge synthesized over time.

#### context

The context label provided when the fact was retained (e.g., `"team meeting"`, `"slack"`). `null` if none was set.

#### metadata

The key-value string pairs attached when the fact was retained. `null` if none were set.

#### tags

The visibility-scoping tags attached to this fact.

#### entities

A list of canonical entity name strings linked to this fact. Only populated when `include.entities` is enabled (the default). `null` otherwise.

#### occurred_start / occurred_end

ISO 8601 datetimes representing when the described event started and ended. Extracted by the LLM from the content during retain. `null` if the content had no temporal information.

#### mentioned_at

ISO 8601 datetime of when this fact was retained into the bank.

#### document_id

The document ID this fact belongs to, as set during retain.

#### chunk_id

The ID of the source text chunk this fact was extracted from. Used to cross-reference with `chunks` in the response when `include.chunks` is enabled.

#### source_fact_ids

For `observation`-type results only: the IDs of the original facts this observation was synthesized from. Cross-references with `source_facts` in the response. `null` for other types or when `include.source_facts` is not enabled.

#### scores

An object of the per-stage scores for this result. `null` for `source_facts` entries, which are attached by provenance rather than ranked. Fields:

- **`final`** — the score this fact was ranked by (cross-encoder relevance × recency/temporal/evidence boosts). `results` is ordered by it descending. A relative signal, not a calibrated probability (see the note above).
- **`reranker`** — the cross-encoder's normalized relevance (`0`–`1`). `null` when the deployment uses a passthrough reranker (RRF/interleave modes), or a reranker that returns rank positions instead of scores (TypeSafe).
- **`semantic`** — the raw vector cosine similarity (`0`–`1`). `null` if this result was not surfaced by semantic search.
- **`keyword`** — the raw keyword/full-text (BM25) score (`≥ 0`, unbounded). `null` if this result was not surfaced by keyword search.

Each field is also a valid [`min_scores`](#min_scores) floor — but `semantic` and `keyword` gate their own retrieval arm rather than the returned result, so a `null` here is expected even when you set that floor. A non-null value always clears it. See [`min_scores`](#min_scores).

---

### source_facts

A dict keyed by fact ID containing full `RecallResult` objects for the source facts that contributed to observation results. Only present when `include.source_facts` is enabled. Facts are deduplicated — if two observations share a source fact, it appears once.

### source_facts_truncated

Whether the token budget cut the `source_facts` map short. When `true`, some IDs in `results[].source_fact_ids` have no entry in `source_facts` because the budget ran out — the references are not dangling. Only present when `include.source_facts` is enabled.

### chunks

A dict keyed by chunk ID containing the raw source text chunks associated with the returned facts. Only present when `include.chunks` is enabled. Each chunk has `id`, `text`, `chunk_index`, and `truncated` (whether the text was cut to fit the token budget).

### entities

A dict keyed by canonical entity name containing entity state objects. Only present when `include.entities` is enabled. Each entry has `entity_id`, `canonical_name`, and `observations`.

### trace

A debug object present only when `trace: true` was set in the request. Contains per-phase timings, retrieval breakdowns, and RRF fusion details.

---

### Hindsight Docs Docs Developer Api Reflect (`hindsight-docs/docs/developer/api/reflect.mdx`)

---
sidebar_position: 3
---

# Reflect

Generate a grounded, disposition-aware response using an agentic reasoning loop.

When you call **reflect**, Hindsight runs an agentic loop that autonomously searches the memory bank using multiple retrieval tools, applies the bank's disposition traits to shape the reasoning style, and produces a final answer grounded in what it found. Unlike recall — which returns raw facts — reflect returns a synthesized response written by the LLM.

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import reflectPy from '!!raw-loader!@site/examples/api/reflect.py';
import reflectMjs from '!!raw-loader!@site/examples/api/reflect.mjs';
import reflectSh from '!!raw-loader!@site/examples/api/reflect.sh';
import reflectGo from '!!raw-loader!@site/examples/api/reflect.go';

:::info How Reflect Works
Learn about disposition-driven reasoning in the [Reflect Architecture](/developer/reflect) guide.
:::

:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::

## Basic Usage

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-basic" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-basic" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-basic" language="go" />
</TabItem>
</Tabs>

---

## Parameters

### query

The question or prompt to reflect on. This is the only required field. If you have situational context that should influence the answer, include it directly in the query rather than as a separate field.

### budget

Controls how thoroughly the agent explores the memory bank before answering. Accepted values are `low` (default), `mid`, and `high`. At `low`, the agent does a shallow search optimized for speed. At `mid`, it checks multiple sources when the question warrants it. At `high`, it performs deep exploration across all knowledge levels and may use multiple query variations to find indirect connections. Use `high` for complex questions that require synthesizing information from many sources.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-with-params" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-with-params" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-with-params" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-with-params" language="go" />
</TabItem>
</Tabs>

### max_tokens

Limits the length of the final generated response. Defaults to `4096`. This does not affect how much the agent can retrieve during the agentic loop — only the final answer length.

### response_schema

An optional JSON Schema **object** with a non-empty `properties` map (nested objects and arrays are supported). When provided, the response includes a `structured_output` field **in addition to** the markdown `text`: the agent reasons to its answer, then a second pass extracts that answer into JSON matching your schema. `structured_output` is therefore a faithful projection of `text` — you get the readable answer *and* a typed object to program against, never one instead of the other. Invalid schemas (not an object, or no properties) are rejected before the call runs.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-structured-output" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-structured-output" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-structured-output" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-structured-output" language="go" />
</TabItem>
</Tabs>

### tags

Defines the visibility scope used throughout the reflect agent. It filters raw
facts, observations, and mental models that the agent can retrieve. The same
`tags` and `tags_match` values also select which tagged directives are injected
into the reflect prompt.

`tags` defaults to `null`, `tags_match` defaults to `any`, and `tag_groups`
defaults to `null`. For non-empty tags, raw facts, observations, and mental
models use the same matching modes as [recall tags](./recall#tags). Directives
have one additional rule: untagged directives are global and remain eligible
whenever a tag scope is supplied, including with a strict or exact match.

| Reflect configuration | Raw facts and observations | Mental models | Active directives |
|-----------------------|----------------------------|---------------|-------------------|
| Omit `tags`, `tags_match`, and `tag_groups` | All tagged and untagged data | All tagged and untagged models | Untagged/global directives only |
| `tags: []`, default `tags_match: "any"` | All tagged and untagged data | All tagged and untagged models | Untagged/global directives only |
| No tags, `tags_match: "exact"` | Untagged/global data only | Untagged/global models only | Untagged/global directives only |
| Non-empty `tags`, `any` or `all` | Matching tagged data plus untagged/global data | Matching models plus untagged/global models | Matching tagged directives plus untagged/global directives |
| Non-empty `tags`, `any_strict` or `all_strict` | Matching tagged data only | Matching tagged models only | Matching tagged directives plus untagged/global directives |
| Non-empty `tags`, `exact` | Data with exactly the requested tag set | Models with exactly the requested tag set | Exactly matching tagged directives plus untagged/global directives |
| Non-empty `tag_groups`, default top-level `tags_match` | Data matching the compound expression | Models matching the compound expression | Matching tagged directives plus untagged/global directives |

A `tag_groups` leaf may set `resolve: "fuzzy"`, as in
[recall](./recall#fuzzy-leaves); reflect resolves it once, before the agentic
loop starts, so every tool it runs filters on the same tags.

The first row is intentionally asymmetric: an unscoped reflect can search all
memories, but it does not load tagged directives. To create a directive that
applies to every reflect call, leave its `tags` empty. To create a scoped
directive, assign tags and pass a matching scope to `reflect`.

:::note `isolation_mode`
`isolation_mode` is an internal `list_directives` option, not a public reflect
request parameter. Reflect always enables it. When neither `tags` nor
`tag_groups` is supplied, it limits directive loading to untagged directives.
There is currently no per-request switch to disable it.
:::

:::note MCP omitted tags
The MCP `reflect` tool forwards `tags_match` only when `tags` is present. To
request the empty exact scope through MCP, pass `tags: []` together with
`tags_match: "exact"`.
:::

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-with-tags" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-with-tags" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-with-tags" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-with-tags" language="go" />
</TabItem>
</Tabs>

#### Common scope examples

Unscoped reflect searches all memories but applies only global directives:

```json
{
  "query": "Summarize the current project status"
}
```

A project scope includes global data and directives alongside matching
`project:a` data and directives:

```json
{
  "query": "Summarize the current project status",
  "tags": ["project:a"]
}
```

A strict project scope excludes untagged memories, observations, and mental
models. Global directives still apply:

```json
{
  "query": "Summarize the current project status",
  "tags": ["project:a"],
  "tags_match": "all_strict"
}
```

### tag_groups

Provides compound tag filtering with recursive `and`, `or`, and `not`
expressions. It affects the same reflect data sources and directive selection as
flat `tags`. `tag_groups` and `tags` are mutually exclusive in the public REST
request. Each leaf supplies its own matching mode and defaults to
`any_strict`; the top-level groups are AND-ed. Normally leave the top-level
`tags_match` at its default, `any`. Setting it to `exact` while using
`tag_groups` additionally constrains facts, observations, and mental models to
the global flat scope before applying the compound expression.

The MCP `reflect` tool currently exposes flat `tags` and `tags_match`, but not
`tag_groups`.

### include

Controls optional supplementary data returned alongside the main response.

#### include.facts

When enabled, the response includes a `based_on` object listing the memories, mental models, and directives the agent actually used to construct the answer. Only sources retrieved during the agent loop can appear here — citations are validated to prevent hallucinated references. Useful for transparency and verification.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-sources" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-sources" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-sources" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-sources" language="go" />
</TabItem>
</Tabs>

#### include.tool_calls

When enabled, the response includes a `trace` object with the full execution log of every tool call and LLM call made during the agentic loop, including inputs, outputs, and durations. Set `output: false` to include only tool inputs for a smaller payload. Useful for debugging why the agent reached a particular conclusion.

### reflect_search_observations_max_tokens

Token budget for the agent's `search_observations` tool when the model names no
budget of its own. Observation evidence is often the largest single contributor
to the reflect context on a big bank, so lowering this trades the lowest-ranked
observations for a smaller (cheaper, faster) LLM context. Defaults to `5000`.

### reflect_search_observations_include_entities

Whether `search_observations` attaches resolved entity names to each
observation. They are useful when the surface text uses an alias ("Bob" for
canonical "Robert Smith"), but they can be more than half the serialized tool
payload; setting `false` keeps the same observations and ranking with a much
smaller context. Defaults to `true`.

### reflect_search_mental_models_max_tokens

Token budget for the mental models (knowledge pages) that the agent's
`search_mental_models` tool returns in full, packed in rank order. The
best-ranked page always comes back in full, even when it alone is over the
budget; the others that do not fit come back as a short snippet, and the model
can open them with `read_mental_models`. Raise it for banks with long pages or
answers spread across several pages; lower it to cut the reflect context.
Defaults to `16000`.

All three options can be defaulted for a whole bank with the
`reflect_default_options` config key (see
[Configuration](../configuration.mdx)); an explicit value on the request always
wins. A mental-model refresh is configured separately and does **not** read
`reflect_default_options` — set the trigger's fields of the same name, or the
bank's `knowledge_page_default_trigger`, instead (see
[Mental Models](./mental-models.mdx)). A refresh always gets only the
best-ranked page in full and the others as snippets, to keep its cost down.

---

## Response

### text

The synthesized answer as a well-formatted markdown string. This is the primary output of reflect. Still returned when `response_schema` is provided — `structured_output` is derived from it, not a replacement for it.

### structured_output

The LLM's response parsed according to the `response_schema` provided in the request. Only present when `response_schema` was set. `null` otherwise.

### structured_output_error

Why the structured view could not be produced. Present only when a `response_schema` was given and the extraction pass failed — a provider error, a timeout, or output that would not parse. The reflect itself still succeeds: you get `200` and the markdown `text`, and this field tells you the machine-readable half is missing because something broke, not because the answer held nothing matching your schema. A missing `structured_output` **without** this field is the latter, ordinary case (the endpoint omits null fields). Treat its presence as retryable, and as the signal to alert on if structured output stops working.

### based_on

The sources the agent used to construct the answer. Only present when `include.facts` was enabled. Contains three fields:

- `memories` — a list of memory facts (world, experience, observation) that were retrieved and cited. Each item has `id`, `text`, `type`, `context`, `occurred_start`, and `occurred_end`.
- `mental_models` — a list of mental models that were used. Each item has `id`, `text`, and `context`.
- `directives` — a list of directives that were enforced during reasoning. Each item has `id`, `name`, and `content`.

### usage

Token usage for all LLM calls made during the agentic loop: `input_tokens`, `output_tokens`, and `total_tokens`. Useful for cost tracking.

### trace

The full execution log of the agentic loop. Only present when `include.tool_calls` was enabled. Contains:

- `tool_calls` — each tool invocation with `tool` name (`lookup`, `recall`, `learn`, `expand`), `input`, `output` (if `output: true`), `duration_ms`, and `iteration` number.
- `llm_calls` — each LLM call with `scope` (e.g., `"agent_1"`, `"final"`) and `duration_ms`.

## When Reflect Fails

Reflect answers from evidence it gathered, so a run that could not gather it does not answer at all — it fails with a **500**, rather than returning a confident reply built on nothing:

- **A retrieval tool raised.** The database, the embedder or the reranker was unavailable. One failure in a batch fails the run: an answer written around a tool that never returned is indistinguishable from one over a bank that genuinely holds nothing on the topic, and callers store it as a real answer.
- **The model produced no answer.** The `done` call arrived empty, or the final synthesis returned nothing.
- **The model or transport cannot drive tool calls.** Reflect is driven entirely by structured tool calls, and requires one on every turn (`tool_choice` `required`, or a named tool on the forced searches); without a decision model, a model with enough evidence says so with `answer_ready` and is then asked for the answer, so it is written once. A transport that silently drops the tool definitions fails loudly so you can switch to a tool-calling-capable model; an endpoint that accepts only `tool_choice: "auto"` fails on those turns too (LM Studio and Ollama, which ignore `required`, are sent `auto`).
- **The provider kept failing.** A non-context-overflow LLM error is retried once inside the loop and then given up on.

A **successful retrieval that returns nothing is not a failure**: the bank genuinely has nothing on the topic, and reflect says so in its answer.

Two cases are deliberately not failures. A run whose *context window* overflows synthesizes from the evidence it has — the prompt was too big for the model, which is a budgeting problem, not a broken dependency. And a tool call the model got *wrong* — a missing argument, a tool that does not exist — is returned to it as an error to fix, not raised.

---

### Hindsight Docs Docs Developer Api Retain (`hindsight-docs/docs/developer/api/retain.mdx`)

---
sidebar_position: 2
---

# 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 Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';

{/* Import raw source files */}
import retainPy from '!!raw-loader!@site/examples/api/retain.py';
import retainMjs from '!!raw-loader!@site/examples/api/retain.mjs';
import retainSh from '!!raw-loader!@site/examples/api/retain.sh';
import retainGo from '!!raw-loader!@site/examples/api/retain.go';

:::info How Retain Works
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
:::

:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::

## Store a Document

A single retain call accepts one or more **items**. Each item is a piece of raw content — a conversation, a document, a note — that Hindsight will analyze and decompose into one or many memories. The content itself is never stored verbatim; what gets stored are the structured facts the LLM extracts from it.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-basic" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-basic" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-basic" language="go" />
</TabItem>
</Tabs>

### Retaining a Conversation

A full conversation should be retained as a single item. The LLM can parse any format — plain text, JSON, Markdown, or any structured representation — as long as it clearly conveys who said what and when. The example below uses a simple `Name (timestamp): text` format.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-conversation" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-conversation" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-conversation" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-conversation" language="go" />
</TabItem>
</Tabs>

When the conversation grows — a new message arrives — just retain again with the full updated content and the same `document_id`. Hindsight will delete the previous version and reprocess from scratch, so memories always reflect the latest state of the conversation.

---

## Parameters

### content

The raw text to store. This is the only required field. Hindsight chunks the content, sends each chunk to the LLM for fact extraction, and stores the resulting structured facts — not the original text. A single `content` value can produce many memories depending on how much information it contains.

### timestamp

When the event described in the content actually occurred. Three forms are accepted:

| Value | Behaviour |
|-------|-----------|
| Omitted / `null` | Defaults to the current time at ingestion. |
| ISO 8601 string (e.g. `"2024-01-15T10:30:00Z"`) | Uses the provided datetime. |
| `"unset"` | Stores the content **without any timestamp**. Use this for timeless material such as reference documents, books, or fictional content where no real event time exists. |

The timestamp is injected into the LLM fact-extraction prompt so the model can resolve relative temporal references in the content — for example, if the content says "last Monday", the model uses the provided timestamp as the anchor to pin down the actual date. When `"unset"` is passed the prompt shows `Event Date: Unknown`, allowing the model to correctly return `N/A` for the `when` field of every extracted fact. Providing a real timestamp also enables temporal recall queries like "What happened last spring?" to work correctly.

### context

A short label describing the source or situation — for example `"team meeting"`, `"slack"`, or `"support ticket"`. It is injected directly into the LLM prompt, so it actively shapes how facts are extracted. The same sentence can mean something very different depending on context: "the project was terminated" in a `"performance review"` context versus a `"product roadmap"` context produces different memories.

Providing context consistently is one of the highest-leverage things you can do to improve memory quality.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-with-context" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-with-context" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-with-context" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-with-context" language="go" />
</TabItem>
</Tabs>

### metadata

Arbitrary key-value string pairs that provide context about this item. For example: `{"source": "slack", "channel": "engineering", "thread_id": "T123"}`. Metadata is included in the fact extraction prompt, so the LLM can use it as additional context when extracting facts — for instance, knowing the document title or source can improve accuracy. It is also stored on each memory unit and returned with every recalled memory, letting you do client-side filtering or static enrichment without extra lookups — for example, linking a memory back to its source URL, thread ID, or any application-specific identifier.

Values are stored and returned as strings. A key whose value is `null` is dropped rather than stored, so it will not come back on the recalled memory — send an empty string if you need the key to survive.

### document_id

A caller-supplied string that groups one or more items under a logical document. This field is the key to making retain **idempotent**.

When you provide a `document_id`, Hindsight upserts the document: if a document with that ID already exists in the bank, it and all its associated memories are deleted before the new content is processed and inserted. This means you can safely re-run retain on updated content — for example, a chat thread that grew since last time — without accumulating duplicate memories.

If you omit `document_id`, Hindsight assigns a random UUID, so re-ingesting the same content will create duplicate memories. How items without a `document_id` are grouped depends on the rest of the request:

| Request | Result |
|---------|--------|
| No item has a `document_id` | All items go into **one** new document. A request too large for one pass (over `HINDSIGHT_API_RETAIN_BATCH_TOKENS`) is split into parts, and each part becomes its own document. |
| Some items have a `document_id`, others don't | Each item without one becomes its **own** new document. It is never folded into another item's document, so replacing or deleting that document leaves it alone. |
| Items share the same `document_id` | Those items go into that one document, in request order. |

An item with an attachment always gets its own document when it has no `document_id`, so it counts as an item *with* one in the table above.

To keep every item separate, give each one its own `document_id`. To put several items in one document, give them the same `document_id`.

### update_mode

Controls how Hindsight handles an existing document when you retain with a `document_id` that already exists.

| Value | Behaviour |
|-------|-----------|
| `"replace"` *(default)* | Deletes the old document and all its memories, then processes the new content from scratch. This is the standard upsert described above. |
| `"append"` | Concatenates the new content onto the existing document text and reprocesses the combined document. Delta retain automatically skips unchanged chunks, so only the new portion triggers LLM extraction. |

Append mode requires a `document_id` — without one there is no existing document to append to.

**When to use append**: Use `"append"` for content that grows incrementally — for example, a log file, a journal, or a chat transcript where you receive new messages one at a time. Instead of re-sending the entire history on each update, send only the new content with `update_mode: "append"` and Hindsight will efficiently merge it with what it already has.

```json
{
  "items": [
    {
      "content": "New entry to add to the existing document.",
      "document_id": "my-growing-doc",
      "update_mode": "append"
    }
  ]
}
```

### entities

A list of entities you want to guarantee are recognized, merged with any entities the LLM extracts automatically. Each entry has a `text` field (the entity name) and an optional `type` (e.g., `"PERSON"`, `"ORG"`, `"CONCEPT"` — defaults to `"CONCEPT"` if omitted).

Use this when you know certain entities are important but the LLM might miss them or refer to them inconsistently across different parts of the content. Providing entities explicitly ensures they are always linked in the knowledge graph.

### resolve_entities

By default the names you pass in `entities` are **resolved** against the entities already in the bank: a name close to an existing one may be matched onto it rather than creating its own entity, based on name similarity plus how strongly it co-occurs with the other names in the same item. That is usually what you want — it is how `Dr. Waller` and `Dr Waller` end up as one entity instead of two.

Set `resolve_entities: false` when the names you are passing are authoritative and must be stored exactly as written. An existing entity is then reused only when its name matches case-insensitively, any other name creates a new entity, and your names are never merged with each other (`Alice` and `Alice Smith` stay two entities).

This applies **only to the entities you supply**. Auto-extracted entities are always resolved, because they are the extractor's guess at a name rather than yours — turning resolution off for them would fill the bank with near-duplicate entities.

The same flag exists on [editing a memory](./memories#resolving-entity-names), where it matters most: a correction you type by hand is exactly the case where a similar existing entity should not win.

### tags and document_tags

Tags control **visibility scoping** — which memories are visible during recall. A memory is only returned if its tags intersect with the tags filter provided in the recall request. This makes tags useful when a single memory bank serves multiple users or sessions and each should only see their own memories.

Use consistent naming patterns to keep tag filtering predictable. Common conventions: `user:<id>` for per-user scoping, `session:<id>` for session isolation, `room:<id>` for chat rooms, `topic:<name>` for category filtering. The bank also exposes a list-tags endpoint that returns all tags with their memory counts, useful for UI autocomplete or wildcard expansion.

See [Recall API](./recall#tags) for filtering by tags during retrieval.

### observation_scopes

Controls which [observations](../observations) this memory contributes to during consolidation. Each scope runs an independent pass, creating or updating observations tagged with only that scope's tags.

:::info Scope isolation
During consolidation, Hindsight uses `all_strict` matching to find existing observations to update — only observations whose tags exactly match the current scope are considered. This keeps scopes isolated: a memory consolidated under `["student:alice"]` will never bleed into an observation tagged `["student:alice", "teacher:bob"]`.
:::

The examples below use a lesson transcript retained with `tags: ["student:alice", "teacher:bob", "session-id:s1"]`.

#### combined *(default)*

One consolidation pass using all tags together. The resulting observation is tagged with the full set.

- Observations created: `["student:alice", "teacher:bob", "session-id:s1"]`
- ✗ *"What does Alice struggle with across all her sessions?"* — no match, because no observation was ever built for `student:alice` alone
- ✗ *"How does Bob teach?"* — no match for `teacher:bob` alone
- ✓ *"What happened in session s1 with Alice and Bob?"* — exact match

**Use when** the memory is meaningful only as a whole and you never need to query any single tag in isolation.

#### shared

One consolidation pass over a single global, **untagged** scope. The memory's own tags are ignored for observation scoping (they stay on the source facts for recall filtering), so memories with *different* tags all consolidate into the **same** observation.

- Observations created: one untagged observation (`[]`)
- ✓ Untagged observations match every recall regardless of tag filter
- Deduplicates across volatile per-call tags: if each session is retained with a unique `session-id:…` tag, `combined` and `per_tag` create a fresh observation every session (the tag never repeats), whereas `shared` folds them all into one.

**Use when** your tags are per-call provenance (e.g. session ids) that you want for recall filtering and debugging but not as a consolidation boundary — keep the tag on `tags` and set `observation_scopes: "shared"`. Documents already retained without it can be moved in place with a [document PATCH](../observations#changing-scopes).

:::caution `shared` vs `[[]]` vs `[]`
`shared` is equivalent to the explicit scope `[[]]` — a list containing one empty scope. Do **not** confuse it with `[]` (an empty list), which declares *zero* scopes and silently falls back to `combined`.
:::

#### per_tag

One consolidation pass per individual tag. Each tag gets its own isolated observation that grows with every new memory sharing that tag.

- Observations created: `["student:alice"]` · `["teacher:bob"]` · `["session-id:s1"]`
- ✓ *"What does Alice struggle with across all her sessions?"*
- ✓ *"How does Bob teach?"*
- ✓ *"What happened in session s1?"*
- ✗ *"How does Alice perform specifically with Bob?"* — no observation for the `["student:alice", "teacher:bob"]` combination
- ✗ *"How does Bob teach in online sessions?"* — no observation for `["teacher:bob", "session-id:s1"]`

**Use when** content involves multiple tags that each represent an independent subject — the most common choice for multi-party content like conversations, lessons, or support sessions.

#### all_combinations

One pass per subset of tags — singles, pairs, triples, and so on. For 3 tags that is 7 passes.

- Observations created: all `"per_tag"` scopes above, plus `["student:alice", "teacher:bob"]` · `["student:alice", "session-id:s1"]` · `["teacher:bob", "session-id:s1"]` · `["student:alice", "teacher:bob", "session-id:s1"]`
- ✓ All questions from `"per_tag"` above
- ✓ *"How does Alice perform specifically with Bob?"* — matched by `["student:alice", "teacher:bob"]`

**Use when** you need observations at every granularity — per tag, per pair, per group.

#### custom

Pass an explicit list of tag sets. Each inner list is one scope.

```json
[["student:alice"], ["teacher:bob"], ["teacher:bob", "session-id:s1"]]
```

- Observations created: exactly those three scopes — nothing more
- ✓ *"What does Alice struggle with?"*
- ✓ *"How does Bob teach?"*
- ✓ *"How does Bob teach in session s1 specifically?"*
- ✗ *"What happened in session s1 regardless of teacher?"* — `["session-id:s1"]` alone was not included

**Use when** you know exactly which combinations are meaningful and want to avoid unnecessary passes.

#### Tag globs in custom scopes

A tag containing `*` is a glob, matched against the memory's own tags: `topic:*` stands for whatever `topic:` tags the memory carries. This lets you scope by a tag whose value you don't know when you send the request — typically one added by an [entity label](./memory-banks#entity-labels) with `tag: true` during extraction.

With a memory tagged `["topic:billing", "user:alice", "source:agent-a", "session:7"]`:

| `observation_scopes` | Observations created |
|---|---|
| `[["topic:*"]]` | `["topic:billing"]` |
| `[["topic:*", "user:*"]]` | `["topic:billing", "user:alice"]` |
| `[["topic:*"], ["user:*"]]` | `["topic:billing"]` · `["user:alice"]` |
| `[["topic:*", "team:x"]]` | `["team:x", "topic:billing"]` |

- A glob matching several tags puts all of them in the scope.
- A scope whose globs match nothing is skipped. If every scope is skipped, the memory falls back to `combined` — it never becomes untagged.
- Tags the scope leaves out (`source:`, `session:` above) stay on the memory for recall filtering; they just don't split observations. So memories about the same topic from different agents or sessions consolidate together — and in the same LLM call, which keeps consolidation cost proportional to the number of subjects rather than the number of memories.

:::caution Tags left out of the scope are not on the observation
With `[["topic:*"]]`, Alice's and Bob's billing facts build one observation tagged only `topic:billing`. A recall filtered to `user:alice` won't match it, and anyone who can see `topic:billing` reads both. Add `user:*` to the scope to keep users apart.
:::

#### Bank default

When an item sends no `observation_scopes`, the bank's [`default_observation_scopes`](./memory-banks#default_observation_scopes) applies (`combined` unless set). It accepts any value above, so `[["topic:*"]]` there groups every client's memories by topic without changing any client. The default is stored on each memory at retain, so changing it later doesn't re-scope memories already retained (reprocessing a document applies the current default) — move them with a [document PATCH](../observations#changing-scopes).

### Response

The synchronous retain response includes:

- `success` — whether the operation completed without errors
- `bank_id` — the memory bank that received the content
- `items_count` — number of items processed
- `async` — whether processing ran asynchronously
- `usage` — token usage for the LLM calls (`input_tokens`, `output_tokens`, `total_tokens`), only present for synchronous operations

---

## Batch Ingestion

Multiple items can be submitted in a single request. Batch ingestion is the recommended approach — it reduces network overhead and lets Hindsight optimize extraction across related content.

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-batch" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-batch" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-batch" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-batch" language="go" />
</TabItem>
</Tabs>


---

## Files

Upload files directly — Hindsight converts them to text and extracts memories automatically. File processing always runs asynchronously and returns operation IDs for tracking.

**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF, etc. — OCR support depends on the configured parser), audio (MP3, WAV, FLAC, etc. — transcription), HTML, and plain text formats (TXT, MD, CSV, JSON, YAML, etc.)

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-files" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-files" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-files" language="go" />
</TabItem>
</Tabs>

The file retain endpoint always returns asynchronously. The response contains `operation_ids` — one per uploaded file — which you can poll via `GET /v1/default/banks/{bank_id}/operations` to track progress.

Upload up to 10 files per request (max 100 MB total). Each file becomes a separate document with optional per-file metadata:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-files-batch" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-files-batch" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-files" language="go" />
</TabItem>
</Tabs>

:::info File Storage
Uploaded files are stored server-side (PostgreSQL by default, or S3/GCS/Azure for production). Configure storage via `HINDSIGHT_API_FILE_STORAGE_TYPE`. See [Configuration](../configuration#file-processing) for details.
:::

---

## Async Ingestion

For large batches, use async ingestion to avoid blocking your application:

<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-async" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-async" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-async" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-async" language="go" />
</TabItem>
</Tabs>

When `async: true`, the call returns immediately with an `operation_id`. Processing runs in the background via the worker service. No `usage` metrics are returned for async operations.

### Safe Retries with `operation_id`

If an async retain response is lost or times out, you can't tell whether the operation was created. Retrying blindly risks enqueuing the same work twice — duplicate extraction, embeddings, and provider spend.

Supply your own `operation_id` (any UUID) to make retries safe:

```json
{
  "items": [{ "content": "Alice works at Google", "document_id": "conv_123" }],
  "async": true,
  "operation_id": "3f2b8c1a-9d4e-4a7b-9c2f-1e6d5a4b3c2d"
}
```

- Re-submitting with the same `operation_id` returns the original operation and creates no new work, so a retry after a lost acknowledgement is a no-op.
- Reusing an `operation_id` that already belongs to a different operation returns HTTP `409`.
- Omitting `operation_id` keeps the default behavior — each request creates a new operation.
- Generate the id **before** you send the request and reuse it across retries. `operation_id` is ignored for synchronous retain, and requires all items in the request to resolve to a single strategy.

### Cut Costs 50% with Provider Batch APIs

When using async retain, enable the provider Batch API to reduce LLM fact-extraction costs by 50%. OpenAI, Groq, and Gemini all offer this discount in exchange for a processing window of up to 24 hours — a trade-off that's typically invisible when retain already runs in the background.

```bash
export HINDSIGHT_API_RETAIN_BATCH_ENABLED=true
```

Hindsight submits fact extraction calls as a batch job to the provider, polls for completion, and processes results automatically. No changes to your API calls are needed.

:::note
Batch API cost savings require `async=true` in your retain request and a compatible provider (OpenAI, Groq, or Gemini).
:::

---

### Hindsight Docs Docs Developer Api Webhooks (`hindsight-docs/docs/developer/api/webhooks.mdx`)

---
sidebar_position: 10
---

# Webhooks

Hindsight can notify your application in real-time when memory events occur by sending HTTP POST requests to a URL you configure.

## Delivery and Retries

Webhooks are registered per memory bank and fire automatically when matching events occur. Each delivery attempt is tracked, and failed deliveries are retried with exponential backoff:

| Attempt | Delay after failure |
|---------|---------------------|
| 1 | 5 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
| 6 | Permanent failure |

A delivery is considered failed if your endpoint returns a non-2xx status code or does not respond within the configured timeout (default 30 seconds). After 6 failed attempts, the delivery is marked as permanently failed and no further retries are made.

:::info At-least-once delivery
Webhook delivery tasks are queued in the same database transaction as the primary operation (e.g. the retain or consolidation write). This means if the server crashes after committing but before sending, the delivery task survives and will be retried. As a result, **your endpoint may receive the same event more than once** — deduplicate on `event_id` (also sent as the `X-Hindsight-Event-Id` header). Don't use `operation_id` for this: a batch retain fires one event per document under the same operation.
:::

## Verifying Deliveries

When a webhook is registered with a secret, every delivery carries an HMAC-SHA256 signature of the exact request body. Verify it before trusting a payload — the URL alone is not proof the request came from Hindsight.

| Header | Value | Notes |
|--------|-------|-------|
| `X-Hindsight-Event` | The event type, e.g. `retain.completed` | Always sent |
| `X-Hindsight-Event-Id` | The event's `event_id` | Always sent. Same value on every retry and on every webhook the event goes to — use it to deduplicate |
| `X-Hindsight-Delivery-Id` | This event's delivery to this webhook | Always sent. Same value on every retry; matches the `id` listed by `GET /webhooks/{id}/deliveries` |
| `X-Hindsight-Signature` | `sha256=<hex>` over the raw body | Sent when a secret is configured |
| `X-Hub-Signature-256` | Identical to `X-Hindsight-Signature` | The conventional name for this construction, so GitHub-style receivers verify out of the box |
| `X-Hindsight-Signature-V2` | `t=<unix_seconds>,v1=<hex>` over `<t>.<raw body>` | Timestamped variant — use this if you want replay protection |

`X-Hindsight-Signature` and `X-Hub-Signature-256` always carry the same value: same secret, same algorithm, same bytes. Verify whichever one your framework already understands; there is no reason to check both.

:::warning Prefer the timestamped signature
`X-Hindsight-Signature` / `X-Hub-Signature-256` sign the body and nothing else, so they say *this payload came from Hindsight* but not *this payload is fresh*. A delivery captured off the wire stays verifiable forever. `X-Hindsight-Signature-V2` binds the payload to the time it was signed — check that `t` is within a tolerance you choose (five minutes is a common default) and reject anything older. The timestamp is inside the signed string, so it cannot be edited without breaking the MAC. It is re-signed on every retry attempt, so a delivery that is retried hours later still arrives with a fresh `t`.
:::

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, body: bytes, header: str) -> bool:
    """Verify an X-Hindsight-Signature-V2 header against the raw request body."""
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, received = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False  # too old (or too far in the future) — treat as a replay

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received)
```

Two things to get right in any language:

- **Sign the raw bytes.** Re-serializing the parsed JSON changes whitespace and key order, and the signature will not match. Read the body before your framework decodes it.
- **Compare in constant time** (`hmac.compare_digest`, `crypto.timingSafeEqual`, …), never with `==`.

Custom headers set on a webhook's `http_config` cannot override `X-Hindsight-Event`, the id headers, or any signature header, so a receiver can trust those values whatever else is configured.

## Event Types

### `consolidation.completed`

Fired after Hindsight finishes consolidating new memories into observations for a bank.

**Payload:**

```json
{
  "event_id": "3f6c2a9e-8d41-4b7a-9c0e-5e2f1d7a6b48",
  "event": "consolidation.completed",
  "bank_id": "my-bank",
  "operation_id": "a1b2c3d4e5f6",
  "status": "completed",
  "timestamp": "2026-03-04T12:00:00Z",
  "data": {
    "observations_created": 3,
    "observations_updated": 1,
    "observations_deleted": null,
    "error_message": null
  }
}
```

**`data` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `observations_created` | `integer \| null` | Number of new observations created |
| `observations_updated` | `integer \| null` | Number of existing observations updated |
| `observations_deleted` | `integer \| null` | Number of observations deleted |
| `error_message` | `string \| null` | Set when `status` is `"failed"` |

**`status` values:** `"completed"` or `"failed"`

A consolidation that hits a temporary error while another consolidation is already pending for the same bank sends no event: it is not retried, the pending one does the same work and sends its own event.

---

### `retain.completed`

Fired once per document after a retain operation completes (both synchronous and asynchronous). When retaining a batch of N documents, N separate events are fired.

**Payload:**

```json
{
  "event_id": "3f6c2a9e-8d41-4b7a-9c0e-5e2f1d7a6b48",
  "event": "retain.completed",
  "bank_id": "my-bank",
  "operation_id": "a1b2c3d4e5f6",
  "status": "completed",
  "timestamp": "2026-03-04T12:00:01Z",
  "data": {
    "document_id": "doc-abc123",
    "tags": ["meeting", "q1-2026"],
    "memory_unit_count": 12
  }
}
```

**`data` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `document_id` | `string \| null` | The document ID if one was provided in the retain request |
| `tags` | `string[] \| null` | Document-level tags applied during retain |
| `memory_unit_count` | `number \| null` | Memory units the document owns after this retain. `null` when the request carried no `document_id`. |

**Notes:**
- For async retain (`async: true`), `operation_id` matches the `operation_id` returned by the retain API.
- For sync retain, `operation_id` is a generated identifier for tracing purposes.
- One event is fired per content item in the retain request.
- `memory_unit_count: 0` means fact extraction returned nothing for the document. The retain still succeeded and the text is stored, but `recall` and `reflect` search memories — so the document is not retrievable until it is [reprocessed](/developer/retain#when-a-mission-excludes-everything-in-a-document). Watch this field to catch a retain mission that excludes more than intended.

---

### `memory_defense.triggered`

Fired when a bank's [Memory Defense](../memory-defense/index.md) policy acts on a retained item — once per item that is **redacted** or **blocked**. Items that pass cleanly do not fire an event. Requires a Memory Defense policy enabled on the bank and a webhook subscribed to this event type.

**Payload:**

```json
{
  "event_id": "3f6c2a9e-8d41-4b7a-9c0e-5e2f1d7a6b48",
  "event": "memory_defense.triggered",
  "bank_id": "my-bank",
  "operation_id": "a1b2c3d4e5f6",
  "status": "redact",
  "timestamp": "2026-03-04T12:00:02Z",
  "data": {
    "action": "redact",
    "detector": "sensitive_data",
    "document_id": "doc-abc123",
    "matched_types": ["github_token", "aws_access_key"],
    "message": "Sensitive data pattern matched: github_token, aws_access_key"
  }
}
```

**`data` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `action` | `string` | Action taken on the item: `"redact"` or `"block"` |
| `detector` | `string \| null` | The detector that matched (`"sensitive_data"`) |
| `document_id` | `string \| null` | The document ID if one was provided in the retain request |
| `matched_types` | `string[] \| null` | Labels of the redaction patterns that fired (e.g. `github_token`, `ssn_us`) |
| `message` | `string \| null` | Human-readable summary of what matched |

**`status` values:** mirrors `data.action` — `"redact"` or `"block"`.

**Notes:**
- A `redact` event means the secret was scrubbed and the redacted memory was still stored. A `block` event means the item was dropped; if every item in the retain request is blocked, the retain call returns `422`.

---

--- METRICS ---
- Files Extracted: 46
- Estimated Token Budget: ~192824 tokens
- Canonical Reference: https://codewiki.google/github.com/vectorize-io/hindsight
