Compare commits

..
Author SHA1 Message Date
Nicolò Boschi f0c5153861 chore: apply ruff formatting to generate_changelog.py 2026-03-18 17:51:45 +01:00
Nicolò Boschi ef24828356 fix: add agno and hermes integration docs to version-0.4 for production build 2026-03-18 17:45:26 +01:00
Nicolò Boschi db7a0ad3a5 feat: independent versioning for integrations
- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle
2026-03-18 17:36:52 +01:00
2875 changed files with 47970 additions and 483126 deletions
-16
View File
@@ -1,16 +0,0 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.3",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
},
"plugins": [
{
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
}
]
}
-247
View File
@@ -1,247 +0,0 @@
---
name: code-review
description: Review changed code against project standards. Checks for missing tests, dead code, type safety, lint issues, and coding conventions. Run after completing any implementation work.
user_invocable: true
---
# Code Review
Review all changed code against the project's quality standards and coding conventions.
## Code Standards
Read and internalize these standards before writing code. The review steps below verify compliance.
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- Pydantic models for request/response
- Ruff for linting (line-length 120)
- No Python files at project root - maintain clean directory structure
- **Never use multi-item tuple return values** — not even for internal/private functions. Always use a dataclass or Pydantic model. No exceptions, no "it's just two values" shortcuts. If a function returns more than one value, define a named type for it.
### Type Safety with Pydantic Models
**NEVER use raw `dict` types for structured data** — this applies to all code, including internal helpers and private functions. If the dict has known keys, it must be a dataclass or Pydantic model:
- Use Pydantic `BaseModel` for all data structures passed between functions
- Use `@dataclass` for lightweight internal data containers when Pydantic validation isn't needed
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
- Avoid `dict.get()` patterns - use typed model attributes instead
- Parse external data (JSON, API responses) into Pydantic models at the boundary
- This catches type errors at parse time, not deep in business logic
- The only acceptable `dict` usage is for truly dynamic/unknown keys (e.g., arbitrary metadata, JSON blobs with no fixed schema)
```python
# BAD - error-prone dict access
def process(data: dict) -> str:
return data.get("name", "") # No validation, silent failures
# GOOD - typed and validated
class UserData(BaseModel):
name: str
created_at: datetime
@field_validator("created_at", mode="before")
@classmethod
def ensure_tz_aware(cls, v):
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
def process(data: UserData) -> str:
return data.name # Type-safe, validated at construction
```
### TypeScript Style
- Next.js App Router for control plane
- Tailwind CSS with shadcn/ui components
### Code Comments
- **Always comment non-trivial technical decisions** with the reasoning behind the choice. If someone would ask "why is it done this way?", there should be a comment.
- **Keep comments up to date with history** — when changing an approach, update the comment to explain what was tried before and why it was changed. Comments serve as a tracker of previous implementations that likely had problems.
- Don't comment obvious code — only where the "why" isn't self-evident from the code itself.
```python
# BAD - no context for future readers
results = await asyncio.gather(*tasks, return_exceptions=True)
# GOOD - explains the non-obvious choice
# Use return_exceptions=True to avoid cancelling sibling tasks on failure.
# Previously we used TaskGroup but it cancelled all tasks when one failed,
# causing partial writes that left orphaned entity links (see #412).
results = await asyncio.gather(*tasks, return_exceptions=True)
```
### API Layer & Data Access
- **No direct database access in `api/http.py`** (or any API router). HTTP handlers must not build SQL, call `acquire_with_retry` / `conn.fetch` / `conn.fetchrow` / `conn.execute`, or reference `fq_table(...)`. All persistence and queries live in `MemoryEngine` (the engine layer). A handler parses/validates the request, calls an engine method, shapes the HTTP response, and maps domain results to status codes (e.g. a `None` return → 404).
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
### Branch Hygiene
- **Always start new feature branches from `origin/main`** — rebase to ensure a clean base.
- **Only include commits relevant to the PR/branch/feature** — no unrelated changes. If the branch contains commits that don't belong, they must be removed before merging.
### General Principles
- Don't add features, refactor code, or make "improvements" beyond what was asked
- Don't add unnecessary error handling for impossible scenarios
- Don't create helpers or abstractions for one-time operations
- No backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
- Three similar lines of code is better than a premature abstraction
## Review Steps
### 1. Check branch hygiene
- Run `git log --oneline main..HEAD` to list all commits on the branch.
- Verify every commit is relevant to the feature/PR. Flag any unrelated commits.
- Check the branch is based on a recent `origin/main` (no stale base).
### 2. Identify changed files
Run `git diff --name-only HEAD` (unstaged) and `git diff --cached --name-only` (staged) to get all changed files. If there are no local changes, diff against the base branch using `git diff main...HEAD --name-only` and `git diff main...HEAD` to review all commits on the current branch.
### 3. Run linters
```bash
./scripts/hooks/lint.sh
```
Report any failures. Do NOT fix them yourself — just report.
### 4. Check for dead code
For each changed Python file, check for:
- Unused imports (Ruff should catch these, but verify)
- Functions/methods/classes that were added but are never called from anywhere
- Variables assigned but never read
- Commented-out code blocks that should be removed
For each changed TypeScript file, check for:
- Unused imports
- Unused variables or functions
- Commented-out code
### 5. Check type safety (Python)
For each changed Python file, check for violations:
- **No raw `dict` for structured data** — must use Pydantic model or dataclass, even for internal/private functions (only exception: truly dynamic/unknown keys)
- **No multi-item tuple returns** — must use dataclass or Pydantic model, even for internal/private functions (no exceptions)
- **Missing type hints** on function parameters and return types
- **Missing `@field_validator`** for datetime fields that should be timezone-aware
### 6. Check for missing tests
For each new or significantly changed function/endpoint/class:
- Check if there is a corresponding test addition or update
- New API endpoints MUST have integration tests
- New utility functions MUST have unit tests
- Bug fixes SHOULD have a regression test
Flag any new logic that lacks test coverage.
**LLM-behaviour changes need a real-LLM judge test, not MockLLM.** If the change alters how the model interprets a prompt — fact/observation extraction, `fact_type` (world/experience) classification, speaker attribution, instruction-following, prompt wording — there MUST be a test marked `pytest.mark.hs_llm_core` that runs the real pipeline and asserts via `tests.llm_judge.assert_meets_criteria` (not string/enum matching). Flag these as findings:
- A prompt/classification change verified only by MockLLM or string assertions (MockLLM echoes input — such tests pass spuriously). **Should fix.**
- A test that hard-asserts `fact_type == "world"/"experience"` (or other model-decided output) instead of judging it — non-deterministic, will flake across providers/runs. **Should fix** (move the classification check into the judge `criteria`; keep only genuinely deterministic structural asserts direct).
- Deterministic mechanics (prompt assembly, suppression/branching logic) that are covered *only* by a slow LLM test — these should also have fast non-LLM unit tests. **Note.**
See CLAUDE.md → Key Conventions → Testing for the full pattern.
### 7. Check API consistency
If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the OpenAPI specs regenerated? (`./scripts/generate-openapi.sh`)
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 7b. Check API-layer data-access boundary
For each changed handler in `hindsight-api-slim/hindsight_api/api/` (e.g. `http.py`, `mcp.py`):
- **Flag any direct DB access in the handler** — `acquire_with_retry`, `conn.fetch` / `fetchrow` / `execute`, raw SQL strings, or `fq_table(...)`. These are a **must fix**: the query must be moved into a `MemoryEngine` method that returns a typed model, and the handler must call that method.
- **Verify authentication is enforced in the engine** — the handler must delegate to an engine method that authenticates via `request_context` (`_authenticate_tenant`, typically through `get_bank_profile`). A handler that reads/writes tenant-scoped data without an engine method enforcing auth is a **must fix** (tenant data could leak across schemas).
### 8. Check code comments
For each non-trivial change:
- **New non-obvious logic** — is there a comment explaining the reasoning?
- **Changed approach** — does the comment include what was done before and why it changed?
- **Stale comments** — do existing comments near the changed code still accurately describe the behavior?
### 9. Check integration completeness
If any files in `hindsight-integrations/` were added or changed, verify:
- **Tests exist** — the integration must have tests that simulate/exercise the external framework (not just pure unit tests of helpers). Check for a `tests/` directory with meaningful test files.
- **CI job exists** — check `.github/workflows/test.yml` for a corresponding `test-<name>-integration` job. If missing, flag it.
- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh` AND in the `INTEGRATIONS` dict in `hindsight-dev/hindsight_dev/generate_changelog.py` (the changelog generator keeps its own list; a release fails at the changelog step if the name is missing there). If either is missing, flag it.
- **Docs gallery + sidebar entry** — the integration must have an entry in `hindsight-docs/src/data/integrations.json`. This file is the **single source of truth** that drives both the integrations gallery and the docs sidebar (the sidebar category is injected from it at render time across all docs versions). The entry needs an internal `/sdks/integrations/<slug>` `link` and a matching page at `hindsight-docs/docs-integrations/<slug>.md(x)`. The `hindsight-docs/scripts/check-integrations.mjs` build step enforces both directions — forward: every internal JSON entry has a doc page; reverse: every released tag (`integrations/<name>/vX.Y.Z`) appears in the JSON (private infra like `cloudflare-oauth-proxy` is in the script's `EXCLUDED` set). Flag any integration that is released (or being released) but missing from `integrations.json`, and any JSON entry without a doc page. Do **not** hand-edit `versioned_sidebars/*.json` to add integration links — they are positional placeholders filled from the JSON.
- **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.).
### 10. Check MCP tool registration completeness
If any new MCP tools were added or existing tools renamed in `hindsight-api-slim/hindsight_api/mcp_tools.py`:
- **`_ALL_TOOLS` set** in `mcp_tools.py` — must include the new tool name
- **`tools_to_register` default set** in `register_mcp_tools()` in `mcp_tools.py` — must include the new tool name
- **`_SINGLE_BANK_TOOLS` set** in `hindsight-api-slim/hindsight_api/api/mcp.py` — must include the new tool if it is bank-scoped (not a bank-management tool like `list_banks`/`create_bank`)
- **`MCP_TOOL_GROUPS`** in `hindsight-control-plane/src/components/bank-config-view.tsx` — must include the new tool in the appropriate group for the UI tool selector
- **Tool count assertions** in tests (e.g., `test_mcp_tools.py`) — must be updated to reflect the new count
### 11. Check backup/restore table coverage
If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create_table` in `hindsight-api-slim/hindsight_api/alembic/versions/`):
- **`BACKUP_TABLES`** in `hindsight-api-slim/hindsight_api/admin/cli.py` — must include the new table, placed after any table it references via foreign key (parents before children). A missing entry is silent data loss: the table is never backed up, and restore's `TRUNCATE banks CASCADE` wipes any FK-to-banks child (e.g. `mental_models`, `directives`) on restore even though it was never saved.
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
### 11b. Check new config flags update the env template
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
in `hindsight-api-slim/hindsight_api/config.py`):
- **`.env.example`** (repo root) — must add the variable (commented if optional)
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
- Python files at project root (not allowed)
- Missing async patterns (should be async throughout)
- Pydantic models for request/response
- Line length > 120 chars
- New features/code beyond what was asked (over-engineering)
- Unnecessary error handling for impossible scenarios
- Premature abstractions or speculative helpers
- Backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
### 13. Report findings
Present a clear summary organized by severity:
**Must fix** — issues that will break CI or violate hard project rules:
- Unrelated commits on the branch
- Lint failures
- Missing type hints on public functions
- Raw dict usage for structured data (including internal code)
- Multi-item tuple returns (including internal code)
- Missing tests for new endpoints
- Direct DB access (raw SQL / `acquire_with_retry` / `fq_table`) in an `api/` handler instead of a `MemoryEngine` method
- Tenant-scoped data accessed without authentication enforced in the engine (`_authenticate_tenant` / `get_bank_profile`)
- New integration missing tests, CI job, or release-integration.sh entry
- Released/added integration missing from `hindsight-docs/src/data/integrations.json`, or a JSON entry with no `docs-integrations/<slug>` page (fails the docs build via `check-integrations.mjs`)
- New PostgreSQL table missing from `BACKUP_TABLES` in `admin/cli.py` (silent data loss on restore)
**Should fix** — issues that hurt code quality:
- Dead code / unused imports missed by linter
- Missing tests for non-trivial utility functions
- Over-engineering beyond the task scope
**Note** — observations that may or may not need action:
- API changes that might need client regeneration
- Patterns that deviate from nearby code style
For each finding, include the file path, line number, and a brief explanation.
Do NOT auto-fix any issues. Report all findings and let the user decide what to address. If there are no findings, confirm the code looks good.
-116
View File
@@ -1,116 +0,0 @@
---
name: hs-release
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
user_invocable: true
---
# Hindsight Release
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
independently** — use `scripts/release-integration.sh` for those, not this skill.
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
and that the intended fixes are already merged to `main` before you start.
## Step 0 — Pre-flight
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
the worktree that already holds it. If that worktree is dirty with throwaway cruft
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
run the release, then `git stash pop`.
3. **Pitfall:** never pipe the checkout in an `&&` chain like
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
## Step 1 — Cut the release
Run from the worktree on a clean `main`:
```bash
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
```
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
and publishes the packages. It is **not** a PR.
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
`git ls-remote --tags origin v<version>` should return the tag.
## Step 2 — Changelog + blog PR (separate)
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
branch off the new `main`:
```bash
git checkout -b docs-changelog-<version> origin/main
```
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
held in another worktree and the current one has work you don't want to disturb.
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
`directory file conflict`.
### Changelog
```bash
uv run --directory hindsight-dev generate-changelog <version>
```
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
touched docs will still appear — that matches precedent, leave them in the **changelog**.
### Blog post
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
ops changes; each integration ships its own changelog. (Integrations may still appear in the
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
- Validate formatting: `npx prettier --check <blog file>`.
### Sync the docs skill
```bash
./scripts/generate-docs-skill.sh
```
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
it. Expect a one-line `version` diff there; keep it.
### Commit, push, PR
```bash
git add -A
git commit --no-verify -m "docs: changelog and blog post for v<version>"
git push -u origin docs-changelog-<version>
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
```
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
mirror, and the skill `openapi.json` version sync.
## Cleanup
If you created a temporary worktree, remove it once the PR is up
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
stash you popped in Step 0.
+3 -138
View File
@@ -2,24 +2,11 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, atlas, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
# to omit the temperature parameter entirely (required for models that reject explicit
# temperatures, e.g. Azure gpt-5.5). The global override below applies to every operation;
# per-operation overrides (defaults: verification=0.0, retain=0.1, reflect=0.9,
# consolidation=0.0) take precedence.
# HINDSIGHT_API_LLM_TEMPERATURE=none
# HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION=0.0
# HINDSIGHT_API_LLM_TEMPERATURE_RETAIN=0.1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
@@ -36,22 +23,7 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Example: MiniMax configuration (1M context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M3 # or MiniMax-M2.7 for the previous generation
# Example: DeepSeek configuration (https://api.deepseek.com)
# HINDSIGHT_API_LLM_PROVIDER=deepseek
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash # or deepseek-v4-pro / deepseek-chat / deepseek-reasoner
# Example: z.ai configuration (Zhipu GLM series, https://z.ai)
# HINDSIGHT_API_LLM_PROVIDER=zai
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: Atlas Cloud configuration (OpenAI-compatible, https://www.atlascloud.ai)
# HINDSIGHT_API_LLM_PROVIDER=atlas
# HINDSIGHT_API_LLM_API_KEY=your-atlascloud-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-ai/deepseek-v4-pro # reasoning model; also Qwen / GLM / Kimi / MiniMax, etc.
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
@@ -59,30 +31,10 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
# RETAIN_/REFLECT_/CONSOLIDATION_ prefix (e.g. HINDSIGHT_API_RETAIN_LLM_1_PROVIDER).
# HINDSIGHT_API_LLM_1_PROVIDER=groq
# HINDSIGHT_API_LLM_1_API_KEY=your-groq-api-key
# HINDSIGHT_API_LLM_1_MODEL=openai/gpt-oss-120b
# HINDSIGHT_API_LLM_2_PROVIDER=anthropic
# HINDSIGHT_API_LLM_2_API_KEY=your-anthropic-api-key
# Strategy JSON: {"mode": "failover"} or {"mode": "round-robin"}.
# Round-robin accepts optional positive-int "weights" (one per member, primary first).
# HINDSIGHT_API_LLM_STRATEGY={"mode": "failover"}
# API Configuration (Optional)
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Optional retain chunking override for structured logs/transcripts.
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
@@ -92,10 +44,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -103,76 +52,13 @@ HINDSIGHT_API_LOG_LEVEL=info
# For Azure PostgreSQL with DiskANN:
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Text Search Extension (Optional - uses native PostgreSQL full-text search by default)
# Backend options: "native" (default), "vchord", "pg_textsearch", "pgroonga", "pg_search"
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION=native
# Native backend dictionary (only used by HINDSIGHT_API_TEXT_SEARCH_EXTENSION=native)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE=english
# ParadeDB pg_search tokenizer (only used when creating pg_search BM25 indexes).
# Empty uses ParadeDB's default tokenizer: unicode_words.
# Supported values: 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), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
# uses the OpenAI SDK directly and requires Chat Completions image input support.
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# For ONNX provider (local CPU embeddings without an Ollama/TEI sidecar):
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
# HINDSIGHT_API_EMBEDDINGS_ONNX_FILE=onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
# HINDSIGHT_API_EMBEDDINGS_ONNX_MAX_TOKENS=512
# HINDSIGHT_API_EMBEDDINGS_ONNX_POOLING=mean
# HINDSIGHT_API_EMBEDDINGS_ONNX_NORMALIZE=true
# HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX="query: "
# HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX="passage: "
# Optional for local model paths or pre-downloaded artifacts:
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH=/models/multilingual-e5-small/onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=/models/multilingual-e5-small
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx
# HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
# HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1
# For ZeroEntropy zembed-1:
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=zeroentropy
# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY=ze-xxxx
# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_MODEL=zembed-1
# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_DIMENSIONS=1280
# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT=float
# HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_LATENCY=fast
#
# IMPORTANT: Embedding keys require provider-specific names:
# HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER}
# (for example, HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL).
#
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
@@ -196,24 +82,3 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
#
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
# -----------------------------------------------------------------------------
# Control Plane (Optional)
# -----------------------------------------------------------------------------
# Dataplane API URL - where the CP proxies requests to
# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
# Optional: Bearer token the CP sends as `Authorization: Bearer <key>` to the
# dataplane API. Required when the API service is auth-protected; omit for a
# public/unauthenticated API.
# HINDSIGHT_CP_DATAPLANE_API_KEY=your-dataplane-bearer-token
# Optional: Require a shared access key to view the Control Plane UI.
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+2 -4
View File
@@ -22,8 +22,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # fetch tags so check-released-integrations can see them
- uses: actions/setup-node@v6
with:
node-version: 20
@@ -36,7 +34,7 @@ jobs:
env:
UMAMI_URL: https://analytics.hindsight.vectorize.io
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
- uses: actions/upload-pages-artifact@v5
- uses: actions/upload-pages-artifact@v4
with:
path: hindsight-docs/build
deploy:
@@ -46,5 +44,5 @@ jobs:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/deploy-pages@v5
- uses: actions/deploy-pages@v4
id: deployment
-297
View File
@@ -1,297 +0,0 @@
name: Performance Tests
on:
schedule:
# Run daily at 06:00 UTC
- cron: "0 6 * * *"
workflow_dispatch:
inputs:
scale:
description: "Test scale (perf-test)"
type: choice
options:
- tiny
- small
- medium
- large
default: large
suite:
description: "Perf-test suite to run (blank = all)"
type: choice
options:
- ""
- retain
- recall
- recall-with-observations
- recall-temporal
- consolidation
- graph-maintenance
default: ""
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
type: string
default: ""
locomo_skip:
description: "Skip LoComo job"
type: boolean
default: false
obs_skip:
description: "Skip observation-dedup benchmark job"
type: boolean
default: false
obs_dataset:
description: "Obs benchmark dataset substring (blank = English hermes transcript)."
type: string
default: ""
obs_fraction:
description: "Obs benchmark fraction (0-1] of each document to run."
type: string
default: "1.0"
ref:
description: "Git ref to test (branch, tag, or SHA). Defaults to main."
type: string
default: ""
concurrency:
group: perf-test
cancel-in-progress: true
jobs:
perf-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
from sentence_transformers import SentenceTransformer
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Model downloaded successfully')
"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run perf-test
run: |
SUITE_ARG=""
if [ -n "${{ inputs.suite }}" ]; then
SUITE_ARG="--suite ${{ inputs.suite }}"
fi
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
$SUITE_ARG \
--output perf-results.json
- name: Upload perf results
if: always()
uses: actions/upload-artifact@v7
with:
name: perf-results-${{ github.sha }}
path: hindsight-dev/perf-results.json
retention-days: 90
# Publish enriched results (perf JSON + commit metadata) to the dashboard
# repo's gh-pages branch. The static site at
# https://vectorize-io.github.io/hindsight-continuous-performance-monitor/
# reads data/index.json + data/<run>.json and renders charts client-side.
- name: Publish to dashboard
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-perf-results.sh hindsight-dev/perf-results.json
locomo:
if: inputs.locomo_skip != true
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_JUDGE_LLM_PROVIDER: vertexai
HINDSIGHT_API_JUDGE_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_ANSWER_LLM_PROVIDER: vertexai
HINDSIGHT_API_ANSWER_LLM_MODEL: google/gemini-2.5-flash
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
from sentence_transformers import SentenceTransformer
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Model downloaded successfully')
"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run LoComo benchmark
# Curated 3-conversation subset (best/middle/worst by accuracy on the
# last successful full run): conv-26 (best), conv-30 (middle), conv-43
# (worst). Excludes conv-44, the bank with the largest unconsolidated
# set that has been pushing scheduled runs over the per-bank
# _wait_for_consolidation timeout. Override via workflow_dispatch with
# the locomo_conversations input.
run: |
CONVERSATIONS="${{ inputs.locomo_conversations }}"
if [ -z "$CONVERSATIONS" ]; then
CONVERSATIONS="conv-26 conv-30 conv-43"
fi
uv run python hindsight-dev/benchmarks/locomo/locomo_benchmark.py \
--wait-consolidation \
--conversation $CONVERSATIONS
- name: Upload LoComo results
if: always()
uses: actions/upload-artifact@v7
with:
name: locomo-results-${{ github.sha }}
path: hindsight-dev/benchmarks/locomo/results/
retention-days: 90
- name: Publish LoComo to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
obs:
# Observation-dedup quality benchmark: ingests a transcript, drains consolidation
# (serial SyncTaskBackend + embedded pg0 — no external DB / worker), and reports the
# near-duplicate observation rate. Real LLM via VertexAI, mirroring the LoComo job.
if: inputs.obs_skip != true
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_ENABLE_OBSERVATIONS: "true"
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
from sentence_transformers import SentenceTransformer
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Model downloaded successfully')
"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run obs benchmark
# Default to the English hermes transcript at full fraction — a clean, deterministic
# consolidation-dedup signal (the Chinese variant adds a cross-lingual embedding
# confound). Override dataset/fraction via workflow_dispatch.
run: |
DATASET="${{ inputs.obs_dataset }}"
if [ -z "$DATASET" ]; then DATASET="hermes_session_2026-05-15_en"; fi
FRACTION="${{ inputs.obs_fraction }}"
if [ -z "$FRACTION" ]; then FRACTION="1.0"; fi
cd hindsight-dev
uv run python -m benchmarks.obs.obs_benchmark \
--dataset "$DATASET" --fraction "$FRACTION" --wipe-bank --output obs-results.json
- name: Upload obs results
if: always()
uses: actions/upload-artifact@v7
with:
name: obs-results-${{ github.sha }}
path: hindsight-dev/obs-results.json
retention-days: 90
- name: Publish obs to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-obs-results.sh hindsight-dev/obs-results.json
+6 -112
View File
@@ -9,11 +9,7 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing + build-provenance attestations
attestations: write # for actions/attest-build-provenance (Obsidian assets)
# No `contents: write`: we never create releases in this repo. The Obsidian
# plugin's distribution release is pushed to its dedicated repo using
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
@@ -35,10 +31,8 @@ jobs:
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
echo "type=typescript" >> $GITHUB_OUTPUT
else
echo "type=plugin" >> $GITHUB_OUTPUT
echo "type=typescript" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
@@ -51,7 +45,7 @@ jobs:
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -69,44 +63,14 @@ jobs:
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
# ── Plugin integrations (claude-code) — no package to publish ───────────
- name: Plugin release
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Check integration lockfile
if: steps.type.outputs.type == 'typescript'
run: ./scripts/check-integration-lockfiles.sh
# Some integrations depend on workspace packages (hindsight-client,
# hindsight-all, hindsight-agent-sdk) via file: refs. Install from root
# so npm resolves them, then build the workspace deps before the integration.
- name: Install root workspace dependencies
if: steps.type.outputs.type == 'typescript'
run: npm ci
- name: Build workspace deps (hindsight-client, hindsight-all, hindsight-agent-sdk)
if: steps.type.outputs.type == 'typescript'
run: |
npm run build --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-all-npm
npm run build --workspace=hindsight-tools/hindsight-agent-sdk
- name: Install integration dependencies
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm ci
@@ -116,86 +80,16 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# Build-provenance attestations for the Obsidian release assets (community-store
# recommendation). Runs after the build so main.js exists. The assets are
# released in the dedicated repo while the build runs here, so users verify at
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
- name: Attest Obsidian plugin build provenance
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
hindsight-integrations/obsidian/main.js
hindsight-integrations/obsidian/styles.css
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
# We do NOT create a GitHub Release in this monorepo: per-integration
# releases pollute the repo's release list (it's for the core product) and
# steal the "Latest" badge, and BRAT / the community store read a repo's
# *latest* release — not a tag — so they can't target a tag in a monorepo.
#
# Instead this monorepo stays the source of truth, and on each obsidian
# release we mirror hindsight-integrations/obsidian/ → the *root* of
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
# preserved) and cut the BRAT / community-store release *there*.
#
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
# token). The dedicated repo is generated; never edit it directly.
- name: Mirror Obsidian plugin to its dedicated repo
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
env:
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.info.outputs.version }}"
DIST_REPO="vectorize-io/hindsight-obsidian"
OBS_DIR="hindsight-integrations/obsidian"
# `git subtree split` needs full history; the default checkout is shallow.
git fetch --unshallow 2>/dev/null || true
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
# so `git config --local --unset-all` can't remove it and it authenticates
# the push as github-actions[bot] (no access to the dedicated repo → 403).
# The documented way to drop an inherited extraheader is to RESET the list
# with an empty value: since command-line `-c` is read last, the empty
# value clears the accumulated headers (including the included one) at
# request-build time. The dist token then comes from the push URL → a
# single Authorization header.
git subtree split --prefix="$OBS_DIR" -b _obs_dist
git -c "http.https://github.com/.extraheader=" \
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
# to match manifest.json — idempotent so re-runs just refresh the assets.
export GH_TOKEN="$DIST_TOKEN"
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
else
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public --provenance 2>&1)
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
# Treat "already published" as success so re-pointed-tag re-runs stay green.
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
# "equivalent entry already exists in the transparency log" = the identical
# --provenance artifact was already logged on a prior run (Sigstore tlog is
# idempotent); the package is published, so this is benign.
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
-65
View File
@@ -1,65 +0,0 @@
name: Release Tool
on:
push:
tags:
- 'tools/**'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Extract tool info
id: info
run: |
# refs/tags/tools/self-driving-agents/v0.0.1 → tool=self-driving-agents, version=0.0.1
TAG="${GITHUB_REF#refs/tags/}"
TOOL=$(echo "$TAG" | cut -d'/' -f2)
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
echo "tool=$TOOL" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Tool: $TOOL, Version: $VERSION"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
# Tools live under hindsight-tools/ and may depend on workspace packages
# (e.g. @vectorize-io/hindsight-client). Install from root so npm resolves
# workspace deps, then build any required workspace packages first.
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (workspace dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Build hindsight-agent-sdk (workspace dep)
run: npm run build --workspace=hindsight-tools/hindsight-agent-sdk
- name: Build tool
run: npm run build --workspace=hindsight-tools/${{ steps.info.outputs.tool }}
- name: Publish to npm
working-directory: ./hindsight-tools/${{ steps.info.outputs.tool }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+5 -96
View File
@@ -150,55 +150,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-hindsight-all-npm:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-all-npm
- name: Build
run: npm run build --workspace=hindsight-all-npm
- name: Publish to npm
working-directory: ./hindsight-all-npm
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-all-npm
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: hindsight-all-npm
path: hindsight-all-npm/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -266,7 +217,7 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-amd64
@@ -278,7 +229,7 @@ jobs:
target: aarch64-apple-darwin
artifact_name: hindsight
asset_name: hindsight-darwin-arm64
- os: ubuntu-22.04-arm
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-arm64
@@ -314,7 +265,6 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
@@ -411,7 +361,6 @@ jobs:
# Build multi-platform and push to release tags
- name: Build and push release images
id: build
uses: docker/build-push-action@v7
with:
context: .
@@ -423,31 +372,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign published images
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
refs=()
while IFS= read -r tag; do
[[ -z "${tag}" ]] && continue
refs+=("${tag}@${DIGEST}")
done <<< "${TAGS}"
cosign sign --yes "${refs[@]}"
- name: Verify signature on primary tag
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign verify "${IMAGE}@${DIGEST}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/release\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
release-helm-chart:
runs-on: ubuntu-latest
permissions:
@@ -458,7 +382,7 @@ jobs:
- uses: actions/checkout@v6
- name: Install Helm
uses: azure/setup-helm@v5
uses: azure/setup-helm@v4
with:
version: 'latest'
@@ -483,7 +407,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-hindsight-all-npm, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -512,24 +436,12 @@ jobs:
name: control-plane
path: ./artifacts/control-plane
- name: Download hindsight-embed npm wrapper
uses: actions/download-artifact@v8
with:
name: hindsight-all-npm
path: ./artifacts/hindsight-all-npm
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-linux-amd64
path: ./artifacts/rust-cli-linux
- name: Download Rust CLI (Linux ARM)
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-linux-arm64
path: ./artifacts/rust-cli-linux-arm64
- name: Download Rust CLI (macOS Intel)
uses: actions/download-artifact@v8
with:
@@ -560,13 +472,10 @@ jobs:
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# hindsight-embed npm wrapper
cp artifacts/hindsight-all-npm/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
cp artifacts/rust-cli-linux/hindsight-linux-amd64 release-assets/ || true
cp artifacts/rust-cli-linux-arm64/hindsight-linux-arm64 release-assets/ || true
cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true
cp artifacts/rust-cli-darwin-arm64/hindsight-darwin-arm64 release-assets/ || true
# Helm chart
@@ -574,7 +483,7 @@ jobs:
ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
generate_release_notes: true
-71
View File
@@ -1,71 +0,0 @@
name: Sign published images
on:
workflow_dispatch:
inputs:
version:
description: 'Version to sign (without leading v, e.g. 0.6.0)'
required: true
type: string
default: '0.6.0'
permissions:
contents: read
packages: write
id-token: write
jobs:
sign:
name: Sign ${{ matrix.image }}:${{ inputs.version }}${{ matrix.suffix }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { image: hindsight-api, suffix: '' }
- { image: hindsight-api, suffix: '-slim' }
- { image: hindsight-control-plane, suffix: '' }
- { image: hindsight, suffix: '' }
- { image: hindsight, suffix: '-slim' }
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Resolve image digest
id: resolve
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}
TAG: ${{ inputs.version }}${{ matrix.suffix }}
run: |
set -euo pipefail
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ -z "${DIGEST}" || "${DIGEST}" != sha256:* ]]; then
echo "Failed to resolve digest for ${IMAGE}:${TAG} (got: ${DIGEST})" >&2
exit 1
fi
echo "Resolved ${IMAGE}:${TAG} -> ${DIGEST}"
echo "ref=${IMAGE}@${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Sign image
env:
REF: ${{ steps.resolve.outputs.ref }}
run: cosign sign --yes "${REF}"
- name: Verify signature
env:
REF: ${{ steps.resolve.outputs.ref }}
run: |
cosign verify "${REF}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/sign-images\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
+11 -3223
View File
File diff suppressed because it is too large Load Diff
-115
View File
@@ -1,115 +0,0 @@
name: Windows Smoke Test
# Daily smoke test that installs the API on Windows and runs the Python client
# integration tests against a live server. Windows is only exercised by the
# hindsight-embed jobs in test.yml on PRs; this catches Windows-specific
# regressions in the API server + client path (e.g. process spawning, console
# subsystem / ConPTY behaviour, see #1885) that the Linux client jobs miss.
on:
schedule:
# 06:00 UTC daily.
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
windows-client-smoke:
# Don't run on forks: the job needs the org's Vertex AI credentials.
if: github.repository == 'vectorize-io/hindsight'
runs-on: windows-latest
timeout-minutes: 45
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: ${{ github.workspace }}/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Force UTF-8 I/O so the API/CLI's ✓/box-drawing output doesn't crash the
# default Windows cp1252 codec (matches test-embed-windows in test.yml).
PYTHONIOENCODING: utf-8
PYTHONUTF8: "1"
steps:
- uses: actions/checkout@v6
- name: Setup GCP credentials
shell: bash
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> "$GITHUB_ENV"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install API dependencies (all extras - local-ml + embedded pg0)
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Install Python client test dependencies
working-directory: ./hindsight-clients/python
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
# `uv run` re-syncs the project env to its default (no-extras) state before
# running, which drops sentence-transformers / pg0. Pass --all-extras on
# every `uv run` so the local-ml + embedded-db deps stay installed (this is
# the same reason hindsight-embed launches the daemon with `--extra all`).
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --all-extras python -c "from sentence_transformers import SentenceTransformer, CrossEncoder; SentenceTransformer('BAAI/bge-small-en-v1.5'); CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); print('Models downloaded')"
# Start the server and run the client tests in a SINGLE step. On Windows
# runners a process backgrounded with `&` in one step is not reliably kept
# alive for later steps (unlike Linux, where it reparents to init), so the
# server must live in the same shell that runs pytest.
- name: Start API server and run Python client tests
shell: bash
run: |
# Config is read straight from the environment (job-level env + the
# PROJECT_ID exported to GITHUB_ENV above), so no .env file is needed.
# Embedded pg0 is the default when HINDSIGHT_API_DATABASE_URL is unset.
( cd hindsight-api-slim && uv run --all-extras hindsight-api --port 8888 ) > "$RUNNER_TEMP/api-server.log" 2>&1 &
server_pid=$!
echo "Waiting for API server to be ready (pid $server_pid)..."
# pg0 unpacks Postgres + runs initdb on first boot, which is slow on a
# cold Windows runner — give it a generous budget before failing.
ready=false
for i in $(seq 1 300); do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
echo "API server failed to start after 300s"
cat "$RUNNER_TEMP/api-server.log"
exit 1
fi
cd hindsight-clients/python && uv run --extra test pytest tests -v
- name: Show API server logs
if: always()
shell: bash
run: cat "$RUNNER_TEMP/api-server.log" || echo "No API server log found"
+2 -9
View File
@@ -6,7 +6,6 @@ dist/
wheels/
*.egg-info
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
@@ -16,8 +15,6 @@ node_modules/
# Environment variables and local config
.env
.env.bak*
.env.*.bak
docker-compose.yml
docker-compose.override.yml
@@ -53,14 +50,10 @@ hindsight-dev/benchmarks/perf/results/
benchmarks/results/
hindsight-cli/target
hindsight-clients/rust/target
.claude/*
!.claude/skills/
.claude
whats-next.md
TASK.md
# Parked / draft integrations that aren't ready to ship
hindsight-integrations/_drafts/
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
blog-post*
.worktrees/
blog-post*
-7
View File
@@ -1,7 +0,0 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
+64 -135
View File
@@ -11,15 +11,9 @@ Hindsight is an agent memory system that provides long-term memory for AI agents
## Development Commands
### Local Development (API + UI)
```bash
# Start both API server and control plane UI
./scripts/dev/start.sh
```
### API Server (Python/FastAPI)
```bash
# Start API server only (loads .env automatically)
# Start API server (loads .env automatically)
./scripts/dev/start-api.sh
# Run all tests (parallelized with pytest-xdist)
@@ -68,9 +62,8 @@ cd hindsight-control-plane && npm run dev
./scripts/benchmarks/run-locomo.sh
# Performance benchmarks
./scripts/benchmarks/run-perf-test.sh # System perf (mock LLM + pg0)
./scripts/benchmarks/run-perf-test.sh --scale tiny # Quick smoke test
./scripts/benchmarks/run-consolidation.sh
./scripts/benchmarks/run-retain-perf.sh --document <path> # Requires API server running
# Results viewer
./scripts/benchmarks/start-visualizer.sh # View results at localhost:8001
@@ -80,16 +73,17 @@ cd hindsight-control-plane && npm run dev
### Monorepo Structure
- **hindsight-api-slim/**: Core FastAPI server with memory engine (Python, uv)
- **hindsight/**: Embedded Python bundle (hindsight-all package)
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
- **hindsight-cli/**: CLI tool (Rust, cargo, uses progenitor for API client)
- **hindsight-clients/**: Generated SDK clients (Python, TypeScript, Rust)
- **hindsight-docs/**: Docusaurus documentation site
- **hindsight-integrations/**: Framework integrations (LiteLLM, CrewAI, LangGraph, Pydantic AI, AG2, Claude Code, etc.)
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
- **hindsight-dev/**: Development tools and benchmarks
### Core Engine (hindsight-api-slim/hindsight_api/engine/)
- `memory_engine.py`: Main orchestrator for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, VertexAI, Groq, MiniMax, Ollama, LM Studio, LiteLLM, Claude Code
- `memory_engine.py`: Main orchestrator (~170KB) for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, MiniMax, Ollama, LM Studio
- `embeddings.py`: Embedding generation (local sentence-transformers or TEI)
- `cross_encoder.py`: Reranking (local or TEI)
- `entity_resolver.py`: Entity extraction and normalization
@@ -102,13 +96,13 @@ cd hindsight-control-plane && npm run dev
**search/**: Multi-strategy retrieval
- `retrieval.py`: Main retrieval orchestrator
- `graph_retrieval.py`: Graph retrieval abstract base class
- `link_expansion_retrieval.py`: Link expansion graph retrieval
- `graph_retrieval.py`: Entity/relationship graph traversal
- `mpfp_retrieval.py`: Multi-Path Fact Propagation retrieval
- `fusion.py`: Reciprocal rank fusion for combining results
- `reranking.py`: Cross-encoder reranking
### API Layer (hindsight-api-slim/hindsight_api/api/)
- `http.py`: FastAPI HTTP routers for all REST endpoints
- `http.py`: FastAPI HTTP routers (~80KB) for all REST endpoints
- `mcp.py`: Model Context Protocol server implementation
Main operations:
@@ -123,17 +117,12 @@ Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
### Adding Database Migrations
Hindsight runs the same Alembic tree against PostgreSQL and Oracle 23ai. Each
migration file dispatches through `run_for_dialect`, which calls either
`_pg_upgrade` or `_oracle_upgrade` based on the live connection. A pytest lint
(`tests/test_migration_shape.py`) fails CI if a migration omits the dispatcher.
1. **Create a new migration file** in `hindsight-api-slim/hindsight_api/alembic/versions/`:
- File name format: `<revision_id>_<description>.py` (e.g., `f1a2b3c4d5e6_add_new_index.py`)
- Use a unique hex revision ID (12 chars)
- Set `down_revision` to the previous migration's revision ID
2. **Migration template** (the `script.py.mako` template scaffolds this; fill in the bodies):
2. **Migration template**:
```python
"""Description of the migration
@@ -144,58 +133,25 @@ migration file dispatches through `run_for_dialect`, which calls either
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f1a2b3c4d5e6"
down_revision: str | Sequence[str] | None = "<previous_revision_id>"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"CREATE INDEX ... ON {schema}table_name(...)")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}index_name")
def _oracle_upgrade() -> None:
# Oracle 23ai equivalent. Use op.get_bind().exec_driver_sql for forms
# that Alembic core does not model (vector/text indexes, partitions).
op.execute("CREATE INDEX ... ON table_name(...)")
def _oracle_downgrade() -> None:
op.execute("DROP INDEX IF EXISTS index_name")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}index_name")
```
**Dialect-only migrations.** If a change genuinely doesn't apply to one
dialect (e.g. enabling `pg_trgm` is PG-only), omit the unused slot:
```python
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
```
Make the asymmetry deliberate. Don't leave an Oracle slot empty just because
you didn't think about it — copy-pasting a PG migration without the Oracle
half is exactly how schemas drift.
3. **Run migrations locally**:
```bash
# Set database URL and run migrations for the base schema plus all tenants
@@ -208,53 +164,11 @@ migration file dispatches through `run_for_dialect`, which calls either
## Key Conventions
### Code Quality
**Before writing code, read `.claude/skills/code-review/SKILL.md`** for the full coding standards (Python style, type safety, TypeScript style, general principles).
**Always run the lint script after making Python or TypeScript/Node changes:**
```bash
./scripts/hooks/lint.sh
```
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
control-plane files / unused (or unlisted) `package.json` dependencies.
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
Run both locally with:
```bash
./scripts/hooks/check-unused.sh
```
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
### Testing
Most tests are deterministic (MockLLM, pure functions) — assert directly.
**Tests that verify LLM behaviour use a real LLM + an LLM-as-judge.** When the thing under test is *how the model interprets a prompt* (classification, attribution, dimension preservation, instruction-following), MockLLM can't simulate it and exact string/enum asserts flake across providers and runs. Use this pattern instead:
1. Mark the test module `pytestmark = pytest.mark.hs_llm_core` (single-provider; CI runs it in the core-LLM job). Use `hs_llm_mat` only for provider-matrix acceptance tests.
2. Call the real pipeline (`LLMConfig.from_env()`, `_get_raw_config()`), e.g. `extract_facts_from_text(...)`.
3. Assert with the judge, not string matching:
```python
from tests.llm_judge import assert_meets_criteria
facts_summary = "\n".join(f"- [{f.fact_type}] {f.fact}" for f in facts)
await assert_meets_criteria(
response=facts_summary,
criteria="The first-person user statements are classified 'world' and attributed to the user, not the agent.",
context="What the input said and who was speaking.",
)
```
Rules of thumb:
- **Judge anything non-deterministic** — including `fact_type` classification and speaker attribution. Do NOT hard-assert `fact_type == "..."`; pass a `[fact_type] fact` summary to the judge instead. Structural facts that ARE deterministic (counts, presence of a field, that a substring was injected into a prompt) stay as direct asserts in fast unit tests.
- **Split the test surface**: cover the deterministic mechanics (prompt assembly, suppression logic) with fast non-LLM unit tests, and the model-following behaviour with one `hs_llm_core` judge test. (Example pair: `test_narrator_resolution.py` + `test_narrator_context_override.py`.)
- The judge model is independent of the test provider (defaults to Gemini); never judge with the same call you're testing.
This runs the same checks as the pre-commit hook (Ruff for Python, ESLint/Prettier for TypeScript).
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
@@ -286,20 +200,48 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
- Update the client type definition in `lib/api.ts`
- Update any UI components that need to use the new parameter
### Adding New Integrations
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- Pydantic models for request/response
- Ruff for linting (line-length 120)
- No Python files at project root - maintain clean directory structure
- **Never use multi-item tuple return values** - prefer dataclass or Pydantic model for structured returns
Every new integration in `hindsight-integrations/` must satisfy all of the following before it can be merged:
### Type Safety with Pydantic Models
**NEVER use raw `dict` types for structured data.** Always use Pydantic models:
- Use Pydantic `BaseModel` for all data structures passed between functions
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
- Avoid `dict.get()` patterns - use typed model attributes instead
- Parse external data (JSON, API responses) into Pydantic models at the boundary
- This catches type errors at parse time, not deep in business logic
1. **Tests are required** — tests must simulate or exercise the external system (mock the framework's interfaces and verify the integration actually calls Hindsight correctly). Pure unit tests of helper functions are not sufficient.
2. **CI job** — add a test job in `.github/workflows/test.yml` following the existing pattern (e.g., `test-crewai-integration`). The job must build, install deps, and run `uv run pytest tests -v`. Also add the integration to `detect-changes` outputs so it only runs when its files change.
3. **Release process** — add the integration name to the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh` so it can be released via the standard release workflow.
4. **Follow project code standards** — Python style, type safety, no raw dicts for structured data, no multi-item tuple returns (see `.claude/skills/code-review/SKILL.md`).
```python
# BAD - error-prone dict access
def process(data: dict) -> str:
return data.get("name", "") # No validation, silent failures
If any of these are missing, the integration is incomplete and must not be pushed or merged.
# GOOD - typed and validated
class UserData(BaseModel):
name: str
created_at: datetime
### Changelogs
@field_validator("created_at", mode="before")
@classmethod
def ensure_tz_aware(cls, v):
if isinstance(v, str):
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
if v.tzinfo is None:
return v.replace(tzinfo=timezone.utc)
return v
Never add "Unreleased" entries to changelogs (e.g. `hindsight-docs/src/pages/changelog/**`). Changelog entries are written by the release script (`./scripts/release-integration.sh`) when a version is actually cut. If a bug fix or feature needs documenting before release, describe it in the PR/commit — the release tooling will surface it in the published changelog section.
def process(data: UserData) -> str:
return data.name # Type-safe, validated at construction
```
### TypeScript Style
- Next.js App Router for control plane
- Tailwind CSS with shadcn/ui components
### Adding New API Configuration Flags
@@ -313,24 +255,21 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass with type annotation
- **Mark as configurable** by adding to `_CONFIGURABLE_FIELDS` set if the field should be overridable per-tenant/bank via API
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
- Add initialization in `from_env()` method
```python
# Configurable field (can be overridden per-tenant/bank via API)
_CONFIGURABLE_FIELDS = {
# Hierarchical field (can be overridden per-bank)
_HIERARCHICAL_FIELDS = {
...,
"my_setting", # Add here for configurable
"my_setting", # Add here for hierarchical
}
# Static field - just don't add to _CONFIGURABLE_FIELDS
# Static field - just don't add to _HIERARCHICAL_FIELDS
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
so new `HindsightConfig` fields are carried through automatically.
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use hierarchical config in MemoryEngine**:
```python
@@ -350,16 +289,6 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
6. **Env template** (`.env.example`):
- Add the variable to the appropriate section, commented if optional, with a
short inline comment describing it (mirror the documentation entry).
- This file is the single source of truth for the env template:
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
embed/profile configs. After editing `.env.example`, re-copy it to the
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
or the `test_bundled_template_matches_repo_root` sync test will fail.
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
@@ -376,7 +305,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```bash
cp .env.example .env
# Edit .env with the LLM provider/model and credentials for your setup
# Edit .env with LLM API key
# Python deps
uv sync --directory hindsight-api-slim/
@@ -385,10 +314,10 @@ uv sync --directory hindsight-api-slim/
npm install
```
Common LLM settings:
Required env vars:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
+2 -25
View File
@@ -9,36 +9,13 @@ Thanks for your interest in contributing to Hindsight!
git clone [email protected]:vectorize-io/hindsight.git
cd hindsight
```
2. Bootstrap your dev environment in one shot:
```bash
./scripts/dev/setup.sh
```
This is idempotent (safe to re-run) and gets you ready to develop, including
offline. It:
- installs the required toolchains if missing (uv/Python, Node/npm, Rust/cargo),
- creates `.env` from `.env.example` (remember to add your LLM API key),
- configures git hooks,
- installs all Python and Node workspace dependencies,
- pre-downloads the local ML models + tokenizer so the API runs offline,
- builds the TypeScript SDK and the Rust CLI.
Useful flags: `--skip-build` (deps only), `--skip-models` (skip ML model
download), `--with-docs` (also build the docs site), `--force` (rebuild
artifacts). Docker image builds are out of scope. Run
`./scripts/dev/setup.sh --help` for details.
### Manual setup
If you'd rather set things up by hand instead of running the script above:
1. Set up your environment:
2. Set up your environment:
```bash
cp .env.example .env
```
Edit the .env to add LLM API key and config as required
2. Install dependencies:
3. Install dependencies:
```bash
# Python dependencies
uv sync --directory hindsight-api/
+6 -23
View File
@@ -29,7 +29,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
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:
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
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.
@@ -61,16 +61,16 @@ If you need more control over how and when your agent stores and recalls memorie
```bash
export OPENAI_API_KEY=sk-xxx
docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8888 -p 9999:9999 \
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v hindsight-data:/home/hindsight/.pg0 \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `minimax`, and `atlas` ([Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hindsight)). The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, and `minimax`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
@@ -83,8 +83,6 @@ 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.
>API: http://localhost:8888
>UI: http://localhost:9999
@@ -142,8 +140,6 @@ main();
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
@@ -250,7 +246,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -276,7 +272,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
---
@@ -301,19 +297,6 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## 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.
---
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
Generated
+2 -33
View File
@@ -42,16 +42,6 @@
},
"workspace": {
"members": {
"hindsight-all-npm": {
"packageJson": {
"dependencies": [
"npm:@types/node@22",
"npm:tsup@^8.5.1",
"npm:typescript@^5.7.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
@@ -68,7 +58,6 @@
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@chenglou/pretext@^0.0.3",
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
@@ -77,6 +66,7 @@
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
@@ -101,12 +91,11 @@
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.7",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-is@^19.2.4",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
@@ -144,26 +133,6 @@
"npm:typescript@~5.6.2"
]
}
},
"hindsight-tools/hindsight-agent-sdk": {
"packageJson": {
"dependencies": [
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-tools/self-driving-agents": {
"packageJson": {
"dependencies": [
"npm:@clack/prompts@^1.2.0",
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:picocolors@^1.1.0",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
}
}
}
@@ -1,90 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with AlloyDB Omni and ScaNN
# Uses Google's free AlloyDB Omni container image: https://hub.docker.com/r/google/alloydbomni
#
# Usage:
# docker compose -f docker/docker-compose/alloydb/docker-compose.yaml up -d
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: password for the AlloyDB Omni/PostgreSQL user
# - Configure LLM provider variables as needed (see the hindsight service below)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_VERSION: AlloyDB Omni image tag (default: 17)
# - HINDSIGHT_DB_USER: database user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: database name (default: hindsight_db)
services:
db:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
container_name: hindsight-db-alloydb
restart: always
ports:
- "5438:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- alloydb_data:/var/lib/postgresql/data
networks:
- hindsight-net
alloydb-init:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command:
- bash
- -c
- |
echo 'Waiting for AlloyDB Omni to be ready...'
until pg_isready -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user}; do
echo 'AlloyDB Omni is unavailable - sleeping'
sleep 2
done
echo 'AlloyDB Omni is ready - creating ${HINDSIGHT_DB_NAME:-hindsight_db} database'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -c 'CREATE DATABASE ${HINDSIGHT_DB_NAME:-hindsight_db};' 2>/dev/null || echo 'Database already exists'
echo 'Creating vector and alloydb_scann extensions'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS vector;'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE;'
echo 'Database and extensions created successfully'
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: scann
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: native
depends_on:
db:
condition: service_started
alloydb-init:
condition: service_completed_successfully
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
alloydb_data:
-113
View File
@@ -1,113 +0,0 @@
# Hindsight with Claude Code (Claude Pro/Max subscription)
Run Hindsight inside Docker using the `claude-code` LLM provider, backed by
your host machine's Claude Pro or Max subscription credentials.
The standalone Hindsight Docker image ships `claude-agent-sdk` but does **not**
bundle the host `claude` CLI binary or any Claude credentials. This Compose
file bind-mounts the host's CLI install and credentials into the container so
the `claude-code` provider works without an API key.
## When to use this
- You have an active Claude Pro or Max subscription and want to use it for
Hindsight without paying separate Anthropic API costs.
- You want a one-command `docker compose up` instead of a long `docker run`
invocation with many flags.
- You are running on **Linux/amd64** — macOS Docker Desktop and Windows host
paths differ and are not yet covered (please open an issue if you'd like to
contribute a verified recipe for either).
> **Personal-use only.** 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 tools that spoofed the Claude Code client identity; Hindsight uses
> the official Claude Agent SDK instead.
>
> Do not deploy this configuration to shared environments or production. For
> that, use the `anthropic` provider with an API key from the
> [Anthropic Console](https://console.anthropic.com/). Usage counts against
> your Claude Pro/Max subscription limits.
## Prerequisites
- Host has `claude` CLI installed (e.g., `npm install -g @anthropics/claude-code`)
and `claude auth login` has been run successfully.
- `~/.claude.json` and `~/.claude/.credentials.json` exist on the host.
- Host `claude` CLI version is **2.1.128 or newer** — the version bundled with
`claude-agent-sdk` 0.5.x has a protocol incompatibility in containers, so
the recipe overrides it with the host binary.
## Quick start
```bash
# Set your host UID/GID (defaults to 1000:1000 if unset)
export HOST_UID=$(id -u)
export HOST_GID=$(id -g)
docker compose -f docker/docker-compose/claude-code/docker-compose.yaml up -d
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Post-setup (one-time)
After the container starts for the first time, run these commands to fix
permissions and symlink the host `claude` binary into `$PATH`:
```bash
# Make ~/.claude writable by your UID (the CLI writes session/project state)
docker exec --user 0:0 hindsight-claude-code chown $(id -u):$(id -g) /home/hindsight/.claude
docker exec --user 0:0 hindsight-claude-code chmod 755 /home/hindsight/.claude
# Symlink the host claude binary into PATH
docker exec --user 0:0 hindsight-claude-code \
ln -sf /home/hindsight/.local/share/claude/versions/2.1.128 /usr/local/bin/claude
```
If you set `CLAUDE_CLI_VERSION` to a version other than `2.1.128`, update the
symlink path accordingly.
## Notes on the bind-mount surface (every flag is load-bearing)
- **Host `claude` binary required** — the image ships only `claude-agent-sdk`,
not the CLI itself.
- **SDK bundled-binary override** — the override of
`claude_agent_sdk/_bundled/claude` works around a protocol issue in the
bundled v2.1.121 binary inside containers. Once `claude-agent-sdk` ships
with v2.1.128+ this override can be dropped. Set `CLAUDE_CLI_VERSION` to
match your installed version.
- **Single-file credential mounts** — credentials are mounted as individual
`:ro` files rather than a whole-directory `:ro` mount of `~/.claude`,
because the CLI writes session/project state at runtime and a read-only
directory mount silently breaks it.
- **`--user` / `user:`** — the `user: ${HOST_UID}:${HOST_GID}` pattern
requires `chmod 755 /home/hindsight`, which is built into the image since
v0.6.0 (see [#1481](https://github.com/vectorize-io/hindsight/issues/1481)).
- **`~/.hindsight-docker` data directory** — the pg0 data bind mount must be
writable by your host UID (see
[#1483](https://github.com/vectorize-io/hindsight/issues/1483)).
- **Verified** on `linux/amd64` against `ghcr.io/vectorize-io/hindsight:latest`
v0.5.6+.
## Using a different Claude CLI version
If your host has a `claude` version other than 2.1.128, set
`CLAUDE_CLI_VERSION` before starting:
```bash
export CLAUDE_CLI_VERSION=2.2.0
docker compose -f docker/docker-compose/claude-code/docker-compose.yaml up -d
```
Then update the post-setup symlink to match:
```bash
docker exec --user 0:0 hindsight-claude-code \
ln -sf /home/hindsight/.local/share/claude/versions/2.2.0 /usr/local/bin/claude
```
@@ -1,44 +0,0 @@
name: hindsight-claude-code
# Run Hindsight with the claude-code LLM provider, using your host machine's
# Claude Pro/Max subscription credentials. Linux/amd64 only for now.
#
# Quick start:
# docker compose -f docker/docker-compose/claude-code/docker-compose.yaml up -d
#
# See README.md for prerequisites, post-setup steps, and important caveats.
services:
hindsight:
image: ghcr.io/vectorize-io/hindsight:latest
container_name: hindsight-claude-code
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
ports:
- "127.0.0.1:8888:8888"
- "127.0.0.1:9999:9999"
environment:
HOME: /home/hindsight
USER: hindsight
LOGNAME: hindsight
PATH: /usr/local/bin:/usr/bin:/bin:/app/api/.venv/bin
HINDSIGHT_API_LLM_PROVIDER: claude-code
volumes:
# ── Persistent data ────────────────────────────────────────────
# Writable pg0 data directory. Must be writable by HOST_UID.
- ${HOME:-.}/.hindsight-docker:/home/hindsight/.pg0
# ── Claude credentials (read-only, single-file mounts) ────────
# A whole-directory :ro mount of ~/.claude silently breaks the
# CLI, which writes session/project state at runtime — so we
# mount only the two credential files.
- ${HOME}/.claude/.credentials.json:/home/hindsight/.claude/.credentials.json:ro
- ${HOME}/.claude.json:/home/hindsight/.claude.json:ro
# ── Claude CLI install (read-only) ─────────────────────────────
- ${HOME}/.local/share/claude:/home/hindsight/.local/share/claude:ro
# ── SDK bundled-binary override ────────────────────────────────
# The claude-agent-sdk 0.5.x image bundles v2.1.121 which has a
# protocol incompatibility in containers. Override it with the
# host's v2.1.128+ binary. Drop this mount once claude-agent-sdk
# ships with v2.1.128+.
- ${HOME}/.local/share/claude/versions/${CLAUDE_CLI_VERSION:-2.1.128}:/app/api/.venv/lib/python3.11/site-packages/claude_agent_sdk/_bundled/claude:ro
@@ -1,34 +0,0 @@
# Example: custom Hindsight image with non-default local models baked in.
#
# Use this pattern in production when you run a non-default embedder or
# reranker. Baking models into the image removes the runtime dependency on
# HuggingFace and lets the container registry handle caching per node, so
# you don't need a model-cache PVC.
#
# Built on top of the slim image so only the deps and models you actually
# use end up in the final image.
FROM ghcr.io/vectorize-io/hindsight:latest-slim
# Install the local-ml deps required to load sentence-transformers /
# cross-encoder models at runtime. Pinned ranges mirror hindsight-api-slim's
# `local-ml` extra in hindsight-api-slim/pyproject.toml. Use `uv pip
# install` against the image's venv explicitly: the slim image's venv was
# created by `uv sync` and does not ship its own `pip`, so a bare
# `pip install` would fall back to user site-packages and not be visible
# to the runtime python.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'sentence-transformers>=3.3.0' \
'transformers>=4.53.0' \
'torch>=2.6.0'
# Pre-download the models you want to use. Replace these with your own.
# The defaults bundled in the full image are BAAI/bge-small-en-v1.5 and
# cross-encoder/ms-marco-MiniLM-L-6-v2; here we pick multilingual variants
# as a concrete non-default example.
ARG EMBEDDER=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
ARG RERANKER=cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
SentenceTransformer('${EMBEDDER}'); \
CrossEncoder('${RERANKER}')"
@@ -1,81 +0,0 @@
# Hindsight with Custom Local Models
Example Docker Compose setup that builds a Hindsight image with **non-default
local embedder and reranker models baked in at build time**.
This is the recommended pattern for production when you use a non-default
local model: the container registry caches model layers per node, pod
startup is deterministic, and you don't need a model-cache PVC (or any
runtime dependency on HuggingFace).
## When to use this
- You override `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` or
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` to a non-default model.
- You want pod startup to be deterministic and offline-capable.
- You'd otherwise reach for a Helm `modelCache` PVC just to avoid
re-downloading models.
If you're using the **default** local models, the published full image
(`ghcr.io/vectorize-io/hindsight:latest`) already bakes them in — you don't
need this example.
If you're using **external** providers (TEI, OpenAI, Cohere, ...) for
embeddings and reranking, use the slim image directly — no models are
needed in the image.
## Quick start
```bash
export OPENAI_API_KEY=sk-xxx
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Using your own models
Override the build args to bake different models:
```bash
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml build \
--build-arg EMBEDDER=your-org/your-embedder \
--build-arg RERANKER=your-org/your-reranker
```
Then update the matching `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` and
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` values in `docker-compose.yaml` so the
runtime points at the same model IDs.
## Verifying the models are baked in
`docker-compose.yaml` sets `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1`
so that any attempt to download a model at runtime fails loudly instead of
silently re-downloading. If the container starts and serves recall queries
with these set, the models are correctly baked in.
You can also inspect the image directly:
```bash
docker run --rm --entrypoint sh hindsight-custom-models-hindsight \
-c 'ls ~/.cache/huggingface/hub/'
```
## Why not a model-cache PVC?
The Helm chart exposes an optional `api.persistence.modelCache` PVC for
caching downloaded models across pod restarts. Compared to baking models
into the image:
- A PVC adds storage cost — one PVC per worker replica with
`volumeClaimTemplates`.
- `ReadWriteOnce` (the default) pins pods to a node.
- The PVC needs lifecycle management on `helm uninstall` / `helm upgrade`
— without `helm.sh/resource-policy: keep` it is deleted on uninstall;
with it, storage keeps billing forever until manually cleaned up.
- Pod startup still depends on HuggingFace being reachable on first run.
Image layers, by contrast, are pulled once per node and cached for free by
the container runtime, with no orphaned-storage cleanup story.
@@ -1,44 +0,0 @@
name: hindsight-custom-models
# Example: run a custom Hindsight image with non-default local models baked
# in at build time, so pod startup does not depend on HuggingFace at runtime.
#
# Quick start:
# export OPENAI_API_KEY=sk-xxx
# docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
#
# Required environment variables:
# - OPENAI_API_KEY (or configure another LLM provider via HINDSIGHT_API_LLM_*)
services:
hindsight:
build:
context: .
dockerfile: Dockerfile
# Override at build time to bake different models:
# docker compose build --build-arg EMBEDDER=your-org/your-embedder
args:
EMBEDDER: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
RERANKER: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
container_name: hindsight-custom-models
ports:
- "8888:8888"
- "9999:9999"
environment:
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Point Hindsight at the models baked into the image above.
HINDSIGHT_API_EMBEDDINGS_PROVIDER: local
HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
HINDSIGHT_API_RERANKER_PROVIDER: local
HINDSIGHT_API_RERANKER_LOCAL_MODEL: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
# Fail fast if a model is missing from the image instead of silently
# falling back to a HuggingFace download at runtime.
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
volumes:
- pg_data:/home/hindsight/.pg0
volumes:
pg_data:
-103
View File
@@ -1,103 +0,0 @@
# Hindsight with a local llama.cpp server sidecar
Example Docker Compose setup that runs Hindsight against a **local
llama.cpp server**, fully offline, with no external API key required.
## Architecture
```
┌────────────┐ HTTP /v1/chat/completions ┌──────────────────────────────┐
│ hindsight │ ──────────────────────────▶ │ llama.cpp server (sidecar) │
│ (API + CP) │ │ ghcr.io/ggml-org/llama.cpp │
└────────────┘ └──────────────────────────────┘
```
`llama.cpp` runs as its own container and exposes an OpenAI-compatible
HTTP API. Hindsight talks to it via the standard `openai` LLM provider
with `HINDSIGHT_API_LLM_BASE_URL` pointed at the sidecar.
This pattern follows
[*Hosting llama-server with Docker* (ServiceStack)](https://servicestack.net/posts/hosting-llama-server).
### Why a sidecar and not the in-process `llamacpp` provider?
Hindsight does ship an in-process `llamacpp` provider that spawns
`llama-cpp-python`, but the **published `ghcr.io/vectorize-io/hindsight`
image deliberately omits `llama-cpp-python`** to keep the image small and
avoid bundling native inference libraries that most users don't need.
Trying to set `HINDSIGHT_API_LLM_PROVIDER=llamacpp` against the published
image fails with `ModuleNotFoundError: No module named 'llama_cpp'`.
The sidecar approach side-steps that entirely: the official llama.cpp
image is used as-is for inference, Hindsight is used as-is for memory.
Clean separation, no derived images.
## Quick start
```bash
docker compose -f docker/docker-compose/local-llm/docker-compose.yaml up
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
**First boot downloads ~3.5 GB** (Gemma 4 E2B Q4_K_M GGUF) into the
`llama_models` named volume. Subsequent boots reuse it.
Hindsight only starts after llama.cpp's `/health` endpoint reports
healthy, so the API will appear "stuck" for a few minutes on the first
run while the model downloads.
## Using a different model
Override the HuggingFace repo / file in `docker-compose.yaml`:
```yaml
environment:
LLAMA_ARG_HF_REPO: bartowski/Qwen2.5-7B-Instruct-GGUF
LLAMA_ARG_HF_FILE: Qwen2.5-7B-Instruct-Q4_K_M.gguf
```
Also update `HINDSIGHT_API_LLM_MODEL` on the `hindsight` service to a
matching alias (the value is sent to llama-server as the OpenAI `model`
field — llama-server is lenient about this but it shows up in logs).
## GPU acceleration
The default compose file targets CPU because not everyone has a GPU. On
CPU, Gemma 4 E2B runs at ~2-3 tokens/sec — fine for a smoke test, but the
retain pipeline (which makes several multi-hundred-token LLM calls per
memory) will time out against Hindsight's default LLM timeout. **For any
real use, run on a GPU.**
### NVIDIA
1. Switch the `llama` service image from `:server` to `:server-cuda`.
2. Uncomment the `LLAMA_ARG_N_GPU_LAYERS: "999"` env var (offload all
layers to GPU).
3. Uncomment the `deploy.resources.reservations.devices` block.
4. Install the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
on the host.
The compose file has all four spots marked with inline comments.
### Apple Silicon / ROCm / Vulkan
The official `ghcr.io/ggml-org/llama.cpp` image only ships CPU and CUDA
variants. For Metal (Apple Silicon), ROCm (AMD), or Vulkan backends,
build llama.cpp yourself with the appropriate flags and reference the
image you build instead. Docker Desktop on macOS cannot pass through the
host GPU to a Linux container in any case — for Apple Silicon, run
llama-server directly on the host and only put Hindsight in Docker.
## Caveats
- llama.cpp's HTTP API is OpenAI-compatible but not 100% feature-parity.
Function/tool calling support depends on the chat template baked into
the GGUF; some retain/reflect flows may behave differently than against
a hosted OpenAI model.
- Small GGUFs (~3 B params) are useful for smoke testing but will
underperform a hosted frontier model on retain quality. Use a larger
GGUF (7-13 B params) for production-quality memory.
- The `llama_models` named volume persists the GGUF across `docker
compose down`/`up` so the model is downloaded once, not every restart.
@@ -1,74 +0,0 @@
name: hindsight-local-llm
# Example: run Hindsight against a local llama.cpp server sidecar — fully
# offline, no external API key needed.
#
# Pattern follows https://servicestack.net/posts/hosting-llama-server :
# llama.cpp runs as its own container exposing an OpenAI-compatible HTTP
# API, and Hindsight talks to it via the `openai` LLM provider with a
# custom `base_url`. This means we can use the published Hindsight image
# unchanged — no derived Dockerfile, no `llama-cpp-python` install on top.
#
# Quick start:
# docker compose -f docker/docker-compose/local-llm/docker-compose.yaml up
#
# First boot downloads the default Gemma 4 E2B GGUF (~3.5 GB) into the
# `llama_models` volume; subsequent boots reuse it.
services:
llama:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: hindsight-local-llm-llama
environment:
LLAMA_ARG_HOST: 0.0.0.0
LLAMA_ARG_PORT: "8080"
# Auto-download a small GGUF from HuggingFace on first start.
# Override these to use a different model.
LLAMA_ARG_HF_REPO: bartowski/google_gemma-4-E2B-it-GGUF
LLAMA_ARG_HF_FILE: google_gemma-4-E2B-it-Q4_K_M.gguf
LLAMA_ARG_CTX_SIZE: "8192"
# Uncomment for NVIDIA GPU (and switch image to :server-cuda):
# LLAMA_ARG_N_GPU_LAYERS: "999"
volumes:
# llama-server stores HuggingFace downloads under ~/.cache/huggingface
# (not ~/.cache/llama.cpp), so mount the named volume there to avoid
# re-downloading the GGUF on every recreate.
- llama_models:/root/.cache/huggingface
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
# For NVIDIA GPU acceleration, swap the image above to
# `ghcr.io/ggml-org/llama.cpp:server-cuda` and uncomment:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
hindsight:
image: ghcr.io/vectorize-io/hindsight:latest
container_name: hindsight-local-llm
depends_on:
llama:
condition: service_healthy
ports:
- "8888:8888"
- "9999:9999"
environment:
# llama-server is OpenAI-compatible, so use the `openai` provider and
# point base_url at the sidecar. The API key is unused by llama-server
# but Hindsight requires the env var to be set.
HINDSIGHT_API_LLM_PROVIDER: openai
HINDSIGHT_API_LLM_BASE_URL: http://llama:8080/v1
HINDSIGHT_API_LLM_API_KEY: not-needed
HINDSIGHT_API_LLM_MODEL: gemma-4-e2b-it
volumes:
- pg_data:/home/hindsight/.pg0
volumes:
pg_data:
llama_models:
@@ -51,8 +51,6 @@ services:
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
# Optional: Require a shared access key for Control Plane UI access
# HINDSIGHT_CP_ACCESS_KEY: your-secret-key
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
@@ -1,7 +0,0 @@
# PostgreSQL with pgvector and ParadeDB pg_search extensions.
#
# The official ParadeDB image ships PostgreSQL with pg_search and pgvector
# already installed, so no build steps are required. We pin to the PG17
# variant for parity with the other Hindsight docker-compose examples
# (vchord, pg_textsearch).
FROM paradedb/paradedb:latest-pg17
@@ -1,96 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and ParadeDB pg_search.
#
# pg_search is the only BM25 backend supported by Hindsight that works with
# Citus, so this is the recommended setup for horizontally scaled deployments.
#
# Usage:
# docker compose -f docker/docker-compose/pg_search/docker-compose.yaml up -d
#
# Required environment variables:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see the hindsight service)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER: ParadeDB pg_search
# tokenizer for new BM25 indexes (default: empty, uses ParadeDB default)
services:
db:
# Use ParadeDB image which bundles pgvector + pg_search
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db
restart: always
ports:
- "5437:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
pg-search-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_search CASCADE;';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: pgvector
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_search
HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER: ${HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER:-}
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
-23
View File
@@ -1,23 +0,0 @@
# PostgreSQL with pgvector and pgroonga extensions.
#
# pgroonga is a multilingual full-text search extension built on Groonga.
# It works out of the box for CJK (Chinese, Japanese, Korean) and other
# non-whitespace-segmented languages via the TokenBigram tokenizer.
FROM groonga/pgroonga:latest-debian-pg17
# Install pgvector on top of the pgroonga base image (which already provides
# pgroonga and the Groonga library).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
postgresql-server-dev-17 \
&& rm -rf /var/lib/apt/lists/*
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
RUN rm -rf /tmp/pgvector && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
@@ -1,91 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and pgroonga
#
# pgroonga provides multilingual BM25 indexing that works out of the box for
# CJK (Chinese, Japanese, Korean) and other non-whitespace-segmented languages.
# Use this recipe if your bank content is not English/European.
#
# docker compose -f docker/docker-compose/pgroonga/docker-compose.yaml down && \
# sleep 2 && \
# docker compose -f docker/docker-compose/pgroonga/docker-compose.yaml up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_PASSWORD: PostgreSQL password (default: hindsight_password)
services:
db:
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db
restart: always
ports:
- "5439:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
pgroonga-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pgroonga CASCADE;';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: pgvector
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pgroonga
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
@@ -1,6 +1,6 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
-10
View File
@@ -50,8 +50,6 @@ WORKDIR /app/api
# Sync dependencies using appropriate extras based on INCLUDE_LOCAL_MODELS
# local-ml: torch, sentence-transformers, transformers, einops, flashrank, mlx (optional)
# embedded-db: pg0-embedded (always included for embedded PostgreSQL support)
# ONNX Runtime embeddings are intentionally not bundled into the official
# standalone image; install the local-onnx extra in custom images when needed.
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
else \
@@ -174,10 +172,6 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
@@ -334,10 +328,6 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
+6 -169
View File
@@ -1,99 +1,6 @@
#!/bin/bash
set -e
# =============================================================================
# Embedded pg0 data integrity check (#675)
#
# When using embedded pg0, check if the data directory has existing PostgreSQL
# data before starting. If the directory exists but appears empty/corrupt
# (e.g., missing PG_VERSION file), log a warning. This helps diagnose data
# loss scenarios where a container restart caused the data directory to be
# wiped despite a volume mount being present.
# =============================================================================
pg0_has_pg_version() {
local pg0_data_dir="$1"
# pg0 has used more than one on-disk layout. Newer standalone images keep
# PostgreSQL data under instances/<name>/data, while older volumes may have
# placed PG_VERSION at or one level below the mount.
[ -f "$pg0_data_dir/PG_VERSION" ] && return 0
compgen -G "$pg0_data_dir"/*/PG_VERSION > /dev/null 2>&1 && return 0
compgen -G "$pg0_data_dir"/instances/*/data/PG_VERSION > /dev/null 2>&1 && return 0
return 1
}
check_pg0_data_integrity() {
local pg0_data_dir="$1"
if [ ! -d "$pg0_data_dir" ]; then
return 0
fi
# Look for actual PostgreSQL data directories (pg0 creates subdirs per instance)
if pg0_has_pg_version "$pg0_data_dir"; then
echo "✅ Existing pg0 data directory detected at $pg0_data_dir"
elif [ "$(ls -A "$pg0_data_dir" 2>/dev/null)" ]; then
echo "⚠️ WARNING: pg0 data directory exists at $pg0_data_dir but no PG_VERSION found."
echo " This may indicate data corruption or an incomplete previous shutdown."
echo " If you see all migrations running from scratch after this, your data may have been lost."
echo " See: https://github.com/vectorize-io/hindsight/issues/675"
fi
return 0
}
# =============================================================================
# Embedded pg0 writability pre-check (#1483)
#
# The container runs as the unprivileged `hindsight` user (UID 1000). When the
# pg0 data directory is a host bind mount (e.g. `-v $HOME/dir:/home/hindsight/.pg0`)
# that is not owned by UID 1000 — the default on macOS Docker Desktop and most
# non-1000 Linux hosts — pg0 fails with the opaque "Permission denied (os error
# 13)". We cannot chown it ourselves without root (and the image is deliberately
# rootless), so we surface an actionable message up front instead.
#
# Docker *named* volumes are seeded with the image directory's ownership (UID
# 1000) on first use, so they avoid this entirely — hence the named-volume
# recommendation below and in the README.
# =============================================================================
check_pg0_writable() {
local pg0_data_dir="$1"
# Only relevant for embedded pg0; an external database doesn't use this dir.
if [ -n "${HINDSIGHT_API_DATABASE_URL:-}" ]; then
return 0
fi
mkdir -p "$pg0_data_dir" 2>/dev/null || true
if touch "$pg0_data_dir/.hindsight-write-test" 2>/dev/null; then
rm -f "$pg0_data_dir/.hindsight-write-test" 2>/dev/null || true
return 0
fi
echo "❌ The embedded database directory $pg0_data_dir is not writable by this container (UID $(id -u))."
echo ""
echo " A host directory was bind-mounted but is not owned by the container user (UID 1000)."
echo " Hindsight runs rootless and cannot fix this for you. Choose one:"
echo ""
echo " • Recommended — use a Docker named volume (auto-owned by the container):"
echo " -v hindsight-data:/home/hindsight/.pg0"
echo ""
echo " • Or keep the host path and run as your host user, chowning it to match:"
echo " sudo chown -R \$(id -u):\$(id -g) <host-directory>"
echo " docker run --user \$(id -u):\$(id -g) -e HOME=/home/hindsight ..."
echo ""
echo " See https://github.com/vectorize-io/hindsight/issues/1483"
return 1
}
if [ "${HINDSIGHT_START_ALL_SOURCE_ONLY:-false}" = "true" ]; then
return 0 2>/dev/null || exit 0
fi
check_pg0_data_integrity "${HOME}/.pg0"
check_pg0_writable "${HOME}/.pg0" || exit 1
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
@@ -164,70 +71,13 @@ if [ "${HINDSIGHT_WAIT_FOR_DEPS:-false}" = "true" ]; then
done
fi
# =============================================================================
# Graceful shutdown handler (#675)
#
# Docker sends SIGTERM on `docker stop`/`docker restart`. Without a trap, child
# processes (hindsight-api + pg0, control-plane) are killed abruptly. For the
# embedded pg0 database this can cause data loss when the data directory is on
# a Docker volume that gets remounted after restart.
#
# The trap forwards SIGTERM to all tracked child PIDs so that:
# - hindsight-api receives the signal and can run its shutdown hooks
# - pg0 gets a clean PostgreSQL shutdown (checkpoint + WAL flush)
# - The control-plane Node.js process exits cleanly
# =============================================================================
# Guard against concurrent cleanup (e.g., child crash + SIGTERM arriving together)
SHUTTING_DOWN=false
cleanup() {
if $SHUTTING_DOWN; then return; fi
SHUTTING_DOWN=true
echo ""
echo "🛑 Received shutdown signal, stopping services gracefully..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null
fi
done
# Give processes time to shut down cleanly (pg0 needs to flush WAL).
# NOTE: Docker's default stop_grace_period is 10s. If you use the default,
# either set stop_grace_period: 30s in your compose file / docker stop -t 30,
# or Docker will SIGKILL the container before this timeout expires.
local timeout=30
for ((i=1; i<=timeout; i++)); do
local all_stopped=true
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
all_stopped=false
break
fi
done
if $all_stopped; then
echo "✅ All services stopped cleanly"
exit 0
fi
sleep 1
done
# Force kill if still running after timeout
echo "⚠️ Timeout reached, forcing shutdown..."
for pid in "${PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null
fi
done
exit 1
}
trap cleanup SIGTERM SIGINT
# Track PIDs for wait
PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
API_HEALTH_URL="${HINDSIGHT_API_HEALTH_URL:-http://localhost:${HINDSIGHT_API_PORT:-8888}/health}"
API_HEALTH_URL="${HINDSIGHT_API_HEALTH_URL:-http://localhost:8888/health}"
API_STARTUP_WAIT_SECONDS="${HINDSIGHT_API_STARTUP_WAIT_SECONDS:-300}"
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
@@ -288,21 +138,8 @@ if [ ${#PIDS[@]} -eq 0 ]; then
exit 1
fi
# Wait for any process to exit (use wait -n with trap-safe loop)
while true; do
# wait -n returns when any child exits; it also returns on signal delivery
# (the trap handler will run and exit, so this loop is just for robustness).
# `&& true` prevents `set -e` from killing the script when wait -n returns
# non-zero (child exited with error or no backgrounded children remain).
wait -n && true
# Check if any tracked PID has exited
for pid in "${PIDS[@]}"; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" 2>/dev/null
exit_code=$?
echo "⚠️ Service (PID $pid) exited with code $exit_code"
# Trigger cleanup for remaining services
cleanup
fi
done
done
# Wait for any process to exit
wait -n
# Exit with status of first exited process
exit $?
-121
View File
@@ -1,121 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HINDSIGHT_START_ALL_SOURCE_ONLY=true
source "$SCRIPT_DIR/start-all.sh"
unset HINDSIGHT_START_ALL_SOURCE_ONLY
TMP_DIR="$(mktemp -d)"
trap 'chmod -R u+rwx "$TMP_DIR" 2>/dev/null || true; rm -rf "$TMP_DIR"' EXIT
assert_contains() {
local output="$1"
local expected="$2"
if [[ "$output" != *"$expected"* ]]; then
echo "Expected output to contain: $expected"
echo "Actual output:"
echo "$output"
exit 1
fi
}
assert_not_contains() {
local output="$1"
local unexpected="$2"
if [[ "$output" == *"$unexpected"* ]]; then
echo "Expected output not to contain: $unexpected"
echo "Actual output:"
echo "$output"
exit 1
fi
}
assert_empty() {
local output="$1"
if [ -n "$output" ]; then
echo "Expected no output, got:"
echo "$output"
exit 1
fi
}
mkdir -p "$TMP_DIR/empty"
assert_empty "$(check_pg0_data_integrity "$TMP_DIR/empty")"
mkdir -p "$TMP_DIR/direct"
touch "$TMP_DIR/direct/PG_VERSION"
direct_output="$(check_pg0_data_integrity "$TMP_DIR/direct")"
assert_contains "$direct_output" "Existing pg0 data directory detected"
assert_not_contains "$direct_output" "WARNING"
mkdir -p "$TMP_DIR/legacy/instance"
touch "$TMP_DIR/legacy/instance/PG_VERSION"
legacy_output="$(check_pg0_data_integrity "$TMP_DIR/legacy")"
assert_contains "$legacy_output" "Existing pg0 data directory detected"
assert_not_contains "$legacy_output" "WARNING"
mkdir -p "$TMP_DIR/nested/instances/hindsight/data"
touch "$TMP_DIR/nested/instances/hindsight/data/PG_VERSION"
nested_output="$(check_pg0_data_integrity "$TMP_DIR/nested")"
assert_contains "$nested_output" "Existing pg0 data directory detected"
assert_not_contains "$nested_output" "WARNING"
mkdir -p "$TMP_DIR/nonempty/instances/hindsight"
touch "$TMP_DIR/nonempty/instances/hindsight/instance.json"
nonempty_output="$(check_pg0_data_integrity "$TMP_DIR/nonempty")"
assert_contains "$nonempty_output" "WARNING: pg0 data directory exists"
echo "start-all pg0 integrity checks passed"
# =============================================================================
# check_pg0_writable (#1483)
# These rely on filesystem permissions, which root bypasses; skip under root.
# =============================================================================
if [ "$(id -u)" != "0" ]; then
# Writable directory: returns 0, prints nothing, leaves no artifact behind.
mkdir -p "$TMP_DIR/writable"
writable_output="$(check_pg0_writable "$TMP_DIR/writable")"
assert_empty "$writable_output"
if [ -e "$TMP_DIR/writable/.hindsight-write-test" ]; then
echo "check_pg0_writable left its write-test file behind"
exit 1
fi
# Non-writable directory: returns 1 with actionable guidance.
mkdir -p "$TMP_DIR/readonly"
chmod 000 "$TMP_DIR/readonly"
set +e
readonly_output="$(check_pg0_writable "$TMP_DIR/readonly" 2>&1)"
readonly_rc=$?
set -e
chmod 755 "$TMP_DIR/readonly"
if [ "$readonly_rc" -eq 0 ]; then
echo "check_pg0_writable should fail on a non-writable directory"
exit 1
fi
assert_contains "$readonly_output" "not writable"
assert_contains "$readonly_output" "hindsight-data:/home/hindsight/.pg0"
assert_contains "$readonly_output" "--user"
# External database configured: skip the check regardless of dir perms.
mkdir -p "$TMP_DIR/extdb"
chmod 000 "$TMP_DIR/extdb"
set +e
HINDSIGHT_API_DATABASE_URL="postgres://x" check_pg0_writable "$TMP_DIR/extdb" >/dev/null 2>&1
extdb_rc=$?
set -e
chmod 755 "$TMP_DIR/extdb"
if [ "$extdb_rc" -ne 0 ]; then
echo "check_pg0_writable should skip when an external database is configured"
exit 1
fi
echo "start-all pg0 writability checks passed"
else
echo "⚠️ Running as root; skipping pg0 writability checks (permissions are bypassed)."
fi
+6
View File
@@ -0,0 +1,6 @@
dependencies:
- name: postgresql
repository: https://charts.bitnami.com/bitnami
version: 15.5.38
digest: sha256:f67c7612736803ece8a669f8ca6b0555f3b78557bc0ecb732aa2e43f0df7750d
generated: "2025-12-10T17:20:57.058794+01:00"
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.4
appVersion: "0.8.4"
version: 0.4.19
appVersion: "0.4.19"
keywords:
- ai
- memory
+3 -3
View File
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | Chart `appVersion` |
| `version` | Default image tag for all components | `0.1.0` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
@@ -95,27 +95,6 @@ spec:
{{- toYaml .Values.api.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.api.resources | nindent 10 }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumeMounts }}
volumeMounts:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.api.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumes }}
volumes:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
persistentVolumeClaim:
claimName: {{ include "hindsight.fullname" . }}-api-model-cache
{{- end }}
{{- with .Values.api.extraVolumes }}
{{- toYaml . | nindent 6 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -1,21 +0,0 @@
{{- if and .Values.api.enabled .Values.api.persistence.modelCache.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "hindsight.fullname" . }}-api-model-cache
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
{{- with .Values.api.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.api.persistence.modelCache.accessModes | nindent 4 }}
{{- if .Values.api.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.api.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.api.persistence.modelCache.size }}
{{- end }}
@@ -95,16 +95,6 @@ spec:
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.worker.resources | nindent 10 }}
{{- if or .Values.worker.persistence.modelCache.enabled .Values.worker.extraVolumeMounts }}
volumeMounts:
{{- if .Values.worker.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.worker.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -117,26 +107,4 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.extraVolumes }}
volumes:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- if .Values.worker.persistence.modelCache.enabled }}
volumeClaimTemplates:
- metadata:
name: model-cache
{{- with .Values.worker.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.worker.persistence.modelCache.accessModes | nindent 8 }}
{{- if .Values.worker.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.worker.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.worker.persistence.modelCache.size }}
{{- end }}
{{- end }}
+3 -62
View File
@@ -13,6 +13,9 @@
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
@@ -64,39 +67,6 @@ api:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Models are downloaded to /home/hindsight/.cache on first use.
# Without persistence, models are re-downloaded on every pod restart.
#
# For production, prefer baking models into a custom image instead of
# enabling this PVC: image layers are pulled once per node and cached
# for free, while a PVC adds storage cost, pins pods to a node
# (ReadWriteOnce), and needs lifecycle management on uninstall/upgrade.
# See docs: developer/installation#bundling-custom-models-in-a-custom-image
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the api container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the api pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -170,35 +140,6 @@ worker:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Uses volumeClaimTemplates since worker is a StatefulSet — one PVC per
# replica. For production, prefer baking models into a custom image; see
# api.persistence.modelCache above and docs:
# developer/installation#bundling-custom-models-in-a-custom-image
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the worker container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the worker pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
*.tgz
.DS_Store
-80
View File
@@ -1,80 +0,0 @@
# @vectorize-io/hindsight-all
Node.js equivalent of the Python [`hindsight-all`](https://pypi.org/project/hindsight-all/) package — programmatic lifecycle manager for a local Hindsight daemon. Use this when you want to embed Hindsight in a Node application without hand-rolling subprocess management.
This package deliberately does **not** ship an HTTP client. Once the daemon is running, talk to it with [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client) against `server.getBaseUrl()`. The two packages compose — one owns the daemon process, the other owns the HTTP API surface.
## Requirements
- **Node.js >= 22** — uses global `fetch` and `AbortSignal.timeout`.
- **`uv` / `uvx`** on `PATH` — used to download and run the underlying `hindsight-embed` daemon on first use. Install via <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",
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: "0",
},
logger: consoleLogger,
});
await server.start();
const client = new HindsightClient({ baseUrl: server.getBaseUrl() });
await client.retain("user-123", "User prefers dark mode and concise answers.", {
documentId: "pref-2026-04-01",
});
const recall = await client.recall("user-123", "what are the user preferences?");
console.log(recall.results);
await server.stop();
```
For a remote Hindsight API, skip `HindsightServer` entirely and just point `HindsightClient` at the remote URL.
## Open config — forward-compatible with new daemon flags
`HindsightServerOptions` is designed so every new environment variable or CLI flag in the underlying Hindsight daemon can be used without waiting for a wrapper release:
- **`env`** accepts an arbitrary `Record<string, string>`. Every entry is exported into the daemon process and written into the profile config via `--env KEY=VALUE`.
- **`extraProfileCreateArgs`** / **`extraDaemonStartArgs`** append raw args to the respective commands.
## Development against a local checkout
If you're hacking on the Python `hindsight-embed` package in the same monorepo, point the server at the local path — it'll use `uv run --directory <path>` instead of `uvx`:
```ts
new HindsightServer({
embedPackagePath: "/path/to/hindsight-embed",
// ...
});
```
## API surface
- `HindsightServer` — daemon lifecycle (`start`, `stop`, `checkHealth`, `getBaseUrl`, `getProfile`).
- `Logger` interface plus `silentLogger` (default) and `consoleLogger` helpers.
- `getEmbedCommand(opts)` — low-level helper that returns the `[cmd, ...args]` tuple used to invoke the underlying Python CLI.
For memory operations (retain, recall, reflect, bank management, stats) use [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client).
## License
MIT
-57
View File
@@ -1,57 +0,0 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.4",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"hindsight",
"hindsight-all",
"memory",
"ai",
"agent",
"long-term-memory",
"llm",
"embedded-server"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-all-npm"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
},
"overrides": {
"rollup": "^4.59.0",
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4",
"vite": ">=8.0.5"
}
}
-36
View File
@@ -1,36 +0,0 @@
import { describe, it, expect } from "vitest";
import { getEmbedCommand } from "./command.js";
describe("getEmbedCommand", () => {
it("defaults to uvx hindsight-embed@latest", () => {
expect(getEmbedCommand()).toEqual(["uvx", "hindsight-embed@latest"]);
});
it("honours an explicit version", () => {
expect(getEmbedCommand({ embedVersion: "0.5.0" })).toEqual(["uvx", "[email protected]"]);
});
it("treats an empty version as latest", () => {
expect(getEmbedCommand({ embedVersion: "" })).toEqual(["uvx", "hindsight-embed@latest"]);
});
it("uses uv run --directory when a local path is given", () => {
expect(getEmbedCommand({ embedPackagePath: "/abs/path" })).toEqual([
"uv",
"run",
"--directory",
"/abs/path",
"hindsight-embed",
]);
});
it("local path takes precedence over version", () => {
expect(getEmbedCommand({ embedPackagePath: "/abs/path", embedVersion: "0.5.0" })).toEqual([
"uv",
"run",
"--directory",
"/abs/path",
"hindsight-embed",
]);
});
});
-25
View File
@@ -1,25 +0,0 @@
/**
* Resolve the command that invokes the `hindsight-embed` Python CLI.
*
* - If `embedPackagePath` is set, runs the package from a local checkout via
* `uv run --directory <path> hindsight-embed`. Used for in-repo development.
* - Otherwise runs it via `uvx hindsight-embed@<version>` so no global install
* is required.
*
* Returns the argv as `[command, ...baseArgs]` suitable for `spawn()` /
* `execFile()` (never shell-interpolated).
*/
export interface EmbedCommandOptions {
/** Version spec passed to uvx (e.g. "latest", "0.5.0"). Default: "latest". */
embedVersion?: string;
/** Local checkout path. When set, overrides `embedVersion` and uses `uv run`. */
embedPackagePath?: string;
}
export function getEmbedCommand(opts: EmbedCommandOptions = {}): string[] {
if (opts.embedPackagePath) {
return ["uv", "run", "--directory", opts.embedPackagePath, "hindsight-embed"];
}
const version = opts.embedVersion && opts.embedVersion.length > 0 ? opts.embedVersion : "latest";
return ["uvx", `hindsight-embed@${version}`];
}
-7
View File
@@ -1,7 +0,0 @@
export { HindsightServer } from "./server.js";
export { getEmbedCommand } from "./command.js";
export { silentLogger, consoleLogger } from "./logger.js";
export type { Logger } from "./logger.js";
export type { EmbedCommandOptions } from "./command.js";
export type { HindsightServerOptions } from "./types.js";
-29
View File
@@ -1,29 +0,0 @@
/**
* Pluggable logger interface.
*
* This package does not own any logging infrastructure — consumers inject
* whatever they want (console, pino, openclaw's logger, a no-op). The default
* is silent so embedding this package never adds noise to an unrelated app.
*/
export interface Logger {
debug(msg: string): void;
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
/** Logger that drops every call. Used when no logger is passed. */
export const silentLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
/** Logger that writes to the standard console. Handy for CLIs and tests. */
export const consoleLogger: Logger = {
debug: (msg) => console.debug(msg),
info: (msg) => console.log(msg),
warn: (msg) => console.warn(msg),
error: (msg) => console.error(msg),
};
-35
View File
@@ -1,35 +0,0 @@
import { describe, it, expect } from "vitest";
import { HindsightServer } from "./server.js";
describe("HindsightServer construction", () => {
it("defaults base URL to http://127.0.0.1:8888", () => {
const server = new HindsightServer();
expect(server.getBaseUrl()).toBe("http://127.0.0.1:8888");
expect(server.getProfile()).toBe("default");
});
it("honours custom profile, port, and host", () => {
const server = new HindsightServer({ profile: "app", port: 9077, host: "0.0.0.0" });
expect(server.getProfile()).toBe("app");
expect(server.getBaseUrl()).toBe("http://0.0.0.0:9077");
});
it("accepts open env pass-through without complaining about unknown keys", () => {
const server = new HindsightServer({
env: {
HINDSIGHT_API_LLM_PROVIDER: "openai",
HINDSIGHT_API_LLM_MODEL: "gpt-4o-mini",
// A field that does not exist today — should still be accepted
HINDSIGHT_FUTURE_FLAG: "enabled",
},
});
expect(server).toBeInstanceOf(HindsightServer);
});
it("exposes checkHealth that returns false when no daemon is running", async () => {
// Random high port that nothing is listening on.
const server = new HindsightServer({ port: 1, readyTimeoutMs: 100 });
const healthy = await server.checkHealth();
expect(healthy).toBe(false);
});
});
-322
View File
@@ -1,322 +0,0 @@
import { spawn } from "child_process";
import { getEmbedCommand } from "./command.js";
import { silentLogger } from "./logger.js";
import type { Logger } from "./logger.js";
import type { HindsightServerOptions } from "./types.js";
const DEFAULT_PORT = 8888;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PROFILE = "default";
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_READY_POLL_INTERVAL_MS = 1_000;
/**
* Manages the lifecycle of a local Hindsight daemon from a Node.js process.
*
* On {@link start}, this class:
* 1. Resolves the `hindsight-embed` command (via `uvx` or a local `uv run`).
* 2. Runs `profile create <name> --merge --port <port> [--env K=V ...]`
* with every entry in {@link HindsightServerOptions.env} forwarded as
* an `--env` flag.
* 3. Runs `daemon --profile <name> start` and waits for the start command
* to exit.
* 4. Polls `http://host:port/health` until it returns `200` or the
* `readyTimeoutMs` budget is exhausted.
*
* On {@link stop}, it runs `daemon --profile <name> stop` and returns once
* the command exits (or after a short grace period).
*
* This is the Node.js equivalent of the Python `hindsight-all` package's
* `HindsightServer`: a thin programmatic lifecycle wrapper around the
* Hindsight daemon. It does NOT ship an HTTP client — once `start()`
* resolves, use `@vectorize-io/hindsight-client` against `getBaseUrl()` for
* retain / recall / reflect.
*
* The class is deliberately transparent about the daemon: new CLI flags or
* environment variables never require a code change here — callers can pass
* them via `env`, `extraProfileCreateArgs`, or `extraDaemonStartArgs`.
*/
export class HindsightServer {
private readonly profile: string;
private readonly port: number;
private readonly host: string;
private readonly baseUrl: string;
private readonly embedVersion: string | undefined;
private readonly embedPackagePath: string | undefined;
private readonly userEnv: Record<string, string | undefined>;
private readonly extraProfileCreateArgs: string[];
private readonly extraDaemonStartArgs: string[];
private readonly platformCpuWorkaround: boolean;
private readonly readyTimeoutMs: number;
private readonly readyPollIntervalMs: number;
private readonly logger: Logger;
constructor(opts: HindsightServerOptions = {}) {
this.profile = opts.profile ?? DEFAULT_PROFILE;
this.port = opts.port ?? DEFAULT_PORT;
this.host = opts.host ?? DEFAULT_HOST;
this.baseUrl = `http://${this.host}:${this.port}`;
this.embedVersion = opts.embedVersion;
this.embedPackagePath = opts.embedPackagePath;
this.userEnv = opts.env ?? {};
this.extraProfileCreateArgs = opts.extraProfileCreateArgs ?? [];
this.extraDaemonStartArgs = opts.extraDaemonStartArgs ?? [];
this.platformCpuWorkaround = opts.platformCpuWorkaround ?? process.platform === "darwin";
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
this.readyPollIntervalMs = opts.readyPollIntervalMs ?? DEFAULT_READY_POLL_INTERVAL_MS;
this.logger = opts.logger ?? silentLogger;
}
/** The base URL the daemon listens on (`http://host:port`). */
getBaseUrl(): string {
return this.baseUrl;
}
/** The profile name this server operates on. */
getProfile(): string {
return this.profile;
}
/**
* Ensure the daemon is configured and running. Idempotent — the underlying
* `profile create --merge` and `daemon start` commands tolerate re-runs.
*/
async start(): Promise<void> {
this.logger.info(`[hindsight] starting daemon for profile "${this.profile}"`);
const env = this.buildEnv();
await this.configureProfile(env);
await this.startDaemon(env);
await this.waitForReady();
this.logger.info(`[hindsight] daemon ready at ${this.baseUrl}`);
}
/** Stop the daemon. Never throws — logs and resolves even on failure. */
async stop(): Promise<void> {
this.logger.info(`[hindsight] stopping daemon for profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [...baseArgs, "daemon", "--profile", this.profile, "stop"];
const child = spawn(cmd, args, { stdio: "pipe" });
this.pipeOutput(child, "daemon.stop");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[hindsight] daemon stop timed out after 5s`);
resolve();
}, 5_000);
child.on("exit", () => {
clearTimeout(timeout);
this.logger.info(`[hindsight] daemon stopped`);
resolve();
});
child.on("error", (err) => {
clearTimeout(timeout);
this.logger.warn(`[hindsight] error stopping daemon: ${err.message}`);
resolve();
});
});
}
/** Probe `/health` once with a short timeout. */
async checkHealth(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(2_000),
});
return res.ok;
} catch {
return false;
}
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
/**
* Merge the process env, the caller-supplied `env`, and (on macOS) the
* embeddings CPU workaround. Caller-supplied values always win over the
* workaround; undefined values are dropped.
*/
private buildEnv(): NodeJS.ProcessEnv {
const merged: NodeJS.ProcessEnv = { ...process.env };
if (this.platformCpuWorkaround && process.platform === "darwin") {
merged["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1";
merged["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1";
}
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged;
}
/**
* Run `profile create <name> --merge --port <port> [--env K=V ...]`.
* Every entry in the merged env that was passed via {@link userEnv} (or
* auto-applied by the CPU workaround) is forwarded as `--env`.
*/
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
this.logger.info(`[hindsight] configuring profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const createArgs = [
...baseArgs,
"profile",
"create",
this.profile,
"--merge",
"--port",
String(this.port),
];
// Forward every env var that the caller intended for the daemon as --env.
// We only forward keys the caller explicitly set (userEnv) plus the CPU
// workaround values — not the entire process.env, to avoid leaking random
// host state into profile config.
const envForProfile = this.collectProfileEnv(env);
for (const [key, value] of Object.entries(envForProfile)) {
createArgs.push("--env", `${key}=${value}`);
}
createArgs.push(...this.extraProfileCreateArgs);
await this.runCommand(cmd, createArgs, env, "profile.create");
}
/** Collect only the env vars that should be written into the profile file. */
private collectProfileEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {};
// 1. User-supplied env — always forwarded.
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
out[key] = value;
}
}
// 2. CPU workaround — only if auto-applied and not already overridden.
if (this.platformCpuWorkaround && process.platform === "darwin") {
const cpuKeys = [
"HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU",
"HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU",
];
for (const key of cpuKeys) {
if (!(key in out) && env[key] !== undefined) {
out[key] = env[key] as string;
}
}
}
return out;
}
private async startDaemon(env: NodeJS.ProcessEnv): Promise<void> {
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [
...baseArgs,
"daemon",
"--profile",
this.profile,
"start",
...this.extraDaemonStartArgs,
];
await this.runCommand(cmd, args, env, "daemon.start");
}
/**
* Spawn `cmd` with `args`, pipe its output through the logger, and resolve
* once it exits with code 0. Rejects on non-zero exit or spawn error.
*/
private async runCommand(
cmd: string,
args: string[],
env: NodeJS.ProcessEnv,
label: string
): Promise<void> {
const child = spawn(cmd, args, { stdio: "pipe", env });
let output = "";
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split("\n")) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split("\n")) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
await new Promise<void>((resolve, reject) => {
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} failed with code ${code}: ${output.trim()}`));
}
});
child.on("error", (err) => {
reject(new Error(`${label} failed to spawn: ${err.message}`, { cause: err }));
});
});
}
/** Stream a spawned child's stdout/stderr through the logger without blocking. */
private pipeOutput(child: ReturnType<typeof spawn>, label: string): void {
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().trimEnd().split("\n")) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().trimEnd().split("\n")) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
}
/** Poll `/health` until it succeeds or `readyTimeoutMs` elapses. */
private async waitForReady(): Promise<void> {
const deadline = Date.now() + this.readyTimeoutMs;
let attempt = 0;
while (Date.now() < deadline) {
attempt++;
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(this.readyPollIntervalMs),
});
if (res.ok) {
this.logger.debug(`[hindsight] health check passed (attempt ${attempt})`);
return;
}
} catch {
// expected while the daemon is still booting
}
await new Promise((resolve) => setTimeout(resolve, this.readyPollIntervalMs));
}
throw new Error(
`Hindsight daemon did not become ready within ${this.readyTimeoutMs}ms at ${this.baseUrl}`
);
}
}
-54
View File
@@ -1,54 +0,0 @@
import type { Logger } from "./logger.js";
/**
* Options for {@link HindsightServer}.
*
* The server is intentionally thin and pass-through: anything configurable
* on the daemon side (env vars or CLI flags) can be set here without needing
* a new dedicated option. Use {@link env} for `HINDSIGHT_*` / `OPENAI_API_KEY` /
* custom provider settings, and the two `extra*` arrays to append raw CLI
* args to `profile create` or `daemon start`.
*
* For talking to the daemon after `start()`, use `@vectorize-io/hindsight-client`
* against `server.getBaseUrl()`. This package does not ship its own HTTP
* client.
*/
export interface HindsightServerOptions {
/** Profile name used for `--profile <name>` on every sub-command. Default: `"default"`. */
profile?: string;
/** TCP port the daemon listens on. Default: `8888`. */
port?: number;
/** Hostname the daemon binds to (for health checks). Default: `127.0.0.1`. */
host?: string;
/** Version of the underlying `hindsight-embed` PyPI package to run via `uvx`. Default: `"latest"`. */
embedVersion?: string;
/** Local path to a `hindsight-embed` checkout — takes precedence over `embedVersion`. */
embedPackagePath?: string;
/**
* Environment variables passed to the daemon process AND written into the
* profile via repeated `--env KEY=VALUE` flags. This is the preferred way
* to surface any `HINDSIGHT_API_*` / `HINDSIGHT_EMBED_*` setting — adding a
* new daemon env var never requires a wrapper update.
*
* Values of `undefined` are dropped (so you can spread conditionally).
*/
env?: Record<string, string | undefined>;
/** Extra args appended verbatim to `hindsight-embed profile create <name> --merge ...`. */
extraProfileCreateArgs?: string[];
/** Extra args appended verbatim to `hindsight-embed daemon --profile <name> start ...`. */
extraDaemonStartArgs?: string[];
/**
* On macOS, automatically set
* `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1` and
* `HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1` to avoid Metal/MPS crashes in
* daemon mode. Default: `true` on `darwin`, ignored elsewhere. Any value set
* explicitly in {@link env} wins over the auto-applied value.
*/
platformCpuWorkaround?: boolean;
/** Max time (ms) to wait for `/health` to return 200. Default: `30_000`. */
readyTimeoutMs?: number;
/** Polling interval (ms) while waiting for `/health`. Default: `1_000`. */
readyPollIntervalMs?: number;
/** Optional pluggable logger. Default: silent. */
logger?: Logger;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
outDir: "dist",
clean: true,
sourcemap: true,
bundle: true,
});
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.4"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.4",
"hindsight-api-slim>=0.4.17",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+35 -103
View File
@@ -34,6 +34,7 @@ Using context manager:
"""
import logging
import os
import threading
from typing import Optional
@@ -71,11 +72,8 @@ class HindsightEmbedded:
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
database_url: Optional database URL override (default: profile-specific pg0)
idle_timeout: Seconds before daemon auto-exits when idle (default: 0, disabled)
idle_timeout: Seconds before daemon auto-exits when idle (default: 300)
log_level: Daemon log level (default: "info")
ui: Whether to start the control plane web UI alongside the daemon (default: False)
ui_port: Port for the UI. Defaults to daemon_port + 10000.
ui_hostname: Hostname to bind the UI to. Defaults to "0.0.0.0".
"""
def __init__(
@@ -86,11 +84,8 @@ class HindsightEmbedded:
llm_model: str = "openai/gpt-oss-120b",
llm_base_url: Optional[str] = None,
database_url: Optional[str] = None,
idle_timeout: int = 0,
idle_timeout: int = 300,
log_level: str = "info",
ui: bool = False,
ui_port: Optional[int] = None,
ui_hostname: str = "0.0.0.0",
):
"""
Initialize the embedded client (daemon starts on first use).
@@ -102,11 +97,8 @@ class HindsightEmbedded:
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
database_url: Optional database URL override
idle_timeout: Seconds before daemon auto-exits when idle (0 = disabled)
idle_timeout: Seconds before daemon auto-exits when idle
log_level: Daemon log level
ui: Whether to start the control plane web UI alongside the daemon
ui_port: Port for the UI (defaults to daemon_port + 10000)
ui_hostname: Hostname to bind the UI to (defaults to "0.0.0.0")
"""
self.profile = profile
@@ -125,10 +117,6 @@ class HindsightEmbedded:
if database_url:
self.config["HINDSIGHT_EMBED_API_DATABASE_URL"] = database_url
self._ui = ui
self._ui_port = ui_port
self._ui_hostname = ui_hostname
self._client: Optional[Hindsight] = None
self._lock = threading.Lock()
self._started = False
@@ -142,50 +130,23 @@ class HindsightEmbedded:
self._memories_api: Optional[MemoriesAPI] = None
def _ensure_started(self):
"""Ensure daemon is running (thread-safe), restarting if crashed."""
"""Ensure daemon is running (thread-safe)."""
if self._started and self._client is not None:
if self._manager.is_running(self.profile):
return
# Daemon crashed — reset state and fall through to restart
logger.warning(
"Daemon for profile '%s' is no longer responsive, restarting...",
self.profile,
)
try:
self._client.close()
except Exception:
logger.debug("Error closing stale client", exc_info=True)
self._client = None
self._started = False
return
with self._lock:
# Double-check after acquiring lock
if self._started and self._client is not None:
if self._manager.is_running(self.profile):
return
logger.warning(
"Daemon for profile '%s' is no longer responsive (lock path), restarting...",
self.profile,
)
try:
self._client.close()
except Exception:
logger.debug("Error closing stale client", exc_info=True)
self._client = None
self._started = False
return
if self._closed:
raise RuntimeError(
"Cannot use HindsightEmbedded after it has been closed"
)
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
# Use embed manager interface for daemon management
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
success = self._manager.ensure_running(self.config, self.profile)
if not success:
raise RuntimeError(
f"Failed to start daemon for profile '{self.profile}'"
)
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
# Get daemon URL and create client
daemon_url = self._manager.get_url(self.profile)
@@ -193,15 +154,6 @@ class HindsightEmbedded:
self._started = True
logger.info(f"Connected to daemon at {daemon_url}")
# Start UI if requested
if self._ui:
logger.info(f"Starting UI for profile '{self.profile}'...")
ui_started = self._manager.start_ui(
self.profile, self._ui_port, self._ui_hostname
)
if not ui_started:
logger.warning(f"Failed to start UI for profile '{self.profile}'")
def _cleanup(self, stop_daemon_on_close: bool = False):
"""
Cleanup client resources (idempotent).
@@ -213,47 +165,20 @@ class HindsightEmbedded:
if self._closed:
return
acquired = self._lock.acquire(timeout=5.0)
if not acquired:
# Lock is held by another thread (e.g. _ensure_started).
# Mark closed to prevent new operations but skip shared-state
# teardown — the daemon's idle timeout handles the rest.
logger.warning(
"Cleanup lock acquisition timed out for profile '%s'; "
"marking closed, daemon will idle-stop on its own",
self.profile,
)
self._closed = True
return
try:
with self._lock:
if self._closed:
return
if self._client is not None:
try:
self._client.close()
except Exception:
logger.debug(
"Error closing client for profile '%s'",
self.profile,
exc_info=True,
)
self._client.close()
self._client = None
# Stop UI if it was started
if self._ui and self._started:
logger.info(f"Stopping UI for profile '{self.profile}'...")
self._manager.stop_ui(self.profile, self._ui_port)
# Optionally stop daemon (daemon has idle timeout, so not required)
if stop_daemon_on_close and self._started:
logger.info(f"Stopping daemon for profile '{self.profile}'...")
self._manager.stop(self.profile)
self._closed = True
finally:
self._lock.release()
def close(self, stop_daemon: bool = False):
"""
@@ -276,10 +201,23 @@ class HindsightEmbedded:
This allows HindsightEmbedded to expose all HindsightClient methods
without manually wrapping each one.
"""
# Ensure server is started (and restart if crashed) before proxying
# Ensure server is started before proxying
self._ensure_started()
return getattr(self._client, name)
# Get the attribute from the underlying client
attr = getattr(self._client, name)
# If it's a callable, wrap it to ensure server is started
# (shouldn't be needed since _ensure_started already called, but defensive)
if callable(attr):
def wrapper(*args, **kwargs):
self._ensure_started()
return attr(*args, **kwargs)
return wrapper
return attr
def __enter__(self):
"""Context manager entry - ensures server is started."""
@@ -404,8 +342,11 @@ class HindsightEmbedded:
"""
Get the underlying Hindsight client for direct access.
Ensures daemon is started (and restarts it if it has crashed) before
returning the client.
WARNING: Using this property directly means daemon restarts won't be
handled automatically. Prefer using the API namespaces (banks, mental_models,
directives, memories) or direct method calls on HindsightEmbedded instead.
Ensures daemon is started before returning the client.
Returns:
Hindsight: The underlying client instance
@@ -416,8 +357,9 @@ class HindsightEmbedded:
embedded = HindsightEmbedded(profile="myapp", ...)
# Direct access (not recommended - daemon crashes won't be handled)
client = embedded.client
banks = client.list_banks()
banks = client.list_banks() # If daemon crashes, this will fail
```
"""
self._ensure_started()
@@ -431,15 +373,5 @@ class HindsightEmbedded:
@property
def is_running(self) -> bool:
"""Check if the client is initialized and the daemon is responsive."""
return (
self._started
and not self._closed
and self._client is not None
and self._manager.is_running(self.profile)
)
@property
def ui_url(self) -> str:
"""Get the UI URL for this profile."""
return self._manager.get_ui_url(self.profile)
"""Check if the client is initialized."""
return self._started and not self._closed and self._client is not None
View File
+2 -5
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.4"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.4",
"hindsight-api-slim[all]>=0.4.17",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -20,9 +20,6 @@ hindsight-client = { workspace = true }
hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.4",
]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
@@ -1,56 +0,0 @@
"""
Unit test for _cleanup lock timeout behavior.
Verifies that _cleanup completes even when the lock is held by another thread,
instead of hanging indefinitely (fixes #952).
"""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def test_cleanup_completes_when_lock_held():
"""
_cleanup should complete (best-effort) even when self._lock is held
by another thread, e.g. during a long _ensure_started call.
"""
with patch.dict("sys.modules", {
"hindsight_client": MagicMock(),
"hindsight_embed": MagicMock(),
"hindsight.api_namespaces": MagicMock(),
}):
from hindsight.embedded import HindsightEmbedded
client = HindsightEmbedded.__new__(HindsightEmbedded)
client.profile = "test"
client._lock = threading.Lock()
client._closed = False
client._client = None
client._started = False
client._ui = False
# Simulate another thread holding the lock
client._lock.acquire()
cleanup_done = threading.Event()
def run_cleanup():
client._cleanup()
cleanup_done.set()
t = threading.Thread(target=run_cleanup)
t.start()
# Cleanup should complete within the timeout (5s) + margin
assert cleanup_done.wait(timeout=8.0), (
"_cleanup hung instead of timing out on lock acquisition"
)
# Release the lock from the simulating thread
client._lock.release()
t.join(timeout=1.0)
assert client._closed, "Client should be marked as closed after cleanup"
+17 -121
View File
@@ -15,8 +15,6 @@ import os
import uuid
import pytest
import urllib.request
import json
from hindsight import HindsightEmbedded
@@ -25,20 +23,12 @@ from hindsight import HindsightEmbedded
def llm_config():
"""Get LLM configuration from environment (session-scoped)."""
# Try both naming conventions
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv(
"HINDSIGHT_LLM_PROVIDER", "groq"
)
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv(
"HINDSIGHT_LLM_API_KEY", ""
)
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv(
"HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b"
)
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv("HINDSIGHT_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv("HINDSIGHT_LLM_API_KEY", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv("HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b")
if not api_key:
pytest.skip(
"LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY."
)
pytest.skip("LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY.")
return {
"llm_provider": provider,
@@ -88,9 +78,7 @@ def test_embedded_context_manager(llm_config):
# Recall memory
recall_results = client.recall(bank_id=bank_id, query="context")
assert isinstance(recall_results.results, list), (
"Recall should return results list"
)
assert isinstance(recall_results.results, list), "Recall should return results list"
# Server should be stopped after context exit
# Note: We can't check client.is_running here as client is out of scope
@@ -117,9 +105,7 @@ def test_embedded_complete_workflow(llm_config):
# Step 1: Create a memory bank
print(f"\n1. Creating memory bank: {bank_id}")
bank_response = client.create_bank(
bank_id=bank_id,
name="Test Assistant",
mission="Help with programming tasks",
bank_id=bank_id, name="Test Assistant", mission="Help with programming tasks"
)
assert bank_response.bank_id == bank_id
@@ -140,9 +126,7 @@ def test_embedded_complete_workflow(llm_config):
items=[
{"content": "User works with pandas and numpy."},
{"content": "User likes matplotlib for visualization."},
{
"content": "User is interested in machine learning with scikit-learn."
},
{"content": "User is interested in machine learning with scikit-learn."},
],
)
assert batch_response.success
@@ -150,9 +134,7 @@ def test_embedded_complete_workflow(llm_config):
# Step 4: Recall memories
print("\n4. Recalling memories...")
recall_response = client.recall(
bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000
)
recall_response = client.recall(bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000)
assert isinstance(recall_response.results, list)
assert len(recall_response.results) > 0
print(f" Found {len(recall_response.results)} relevant memories")
@@ -170,9 +152,7 @@ def test_embedded_complete_workflow(llm_config):
# Verify answer mentions relevant tools
answer_lower = reflect_response.text.lower()
assert any(
term in answer_lower for term in ["python", "pandas", "numpy", "data"]
)
assert any(term in answer_lower for term in ["python", "pandas", "numpy", "data"])
# Step 6: List memories
print("\n6. Listing memories...")
@@ -235,9 +215,7 @@ def test_embedded_method_proxying(llm_config):
assert bank.bank_id == bank_id
# Test mission setting
mission_response = client.set_mission(
bank_id=bank_id, mission="Test mission for proxying"
)
mission_response = client.set_mission(bank_id=bank_id, mission="Test mission for proxying")
assert mission_response.bank_id == bank_id
# Test retain
@@ -286,9 +264,7 @@ def test_embedded_multiple_banks(llm_config):
# Create second bank and store data
client.create_bank(bank_id=bank2_id, name="Bank 2")
client.retain(
bank_id=bank2_id, content="Bob uses JavaScript for web development"
)
client.retain(bank_id=bank2_id, content="Bob uses JavaScript for web development")
# Recall from both banks
results1 = client.recall(bank_id=bank1_id, query="programming language")
@@ -299,9 +275,9 @@ def test_embedded_multiple_banks(llm_config):
# Verify banks are isolated (each should only see their own content)
# This is a basic check - content isolation is tested more thoroughly in other tests
assert results1.results[0].text != results2.results[0].text or len(
results1.results
) != len(results2.results)
assert results1.results[0].text != results2.results[0].text or len(results1.results) != len(
results2.results
)
finally:
client.close()
@@ -320,14 +296,10 @@ def test_embedded_profile_isolation(llm_config):
try:
# Store data in profile1
client1.retain(
bank_id=bank_id, content="User likes TypeScript for frontend development"
)
client1.retain(bank_id=bank_id, content="User likes TypeScript for frontend development")
# Store different data in profile2
client2.retain(
bank_id=bank_id, content="User prefers Rust for systems programming"
)
client2.retain(bank_id=bank_id, content="User prefers Rust for systems programming")
# Each profile should only see its own data
results1 = client1.recall(bank_id=bank_id, query="programming preference")
@@ -362,81 +334,5 @@ def test_embedded_error_after_close(llm_config):
assert not client.is_running
# Trying to use it after close should raise an error
with pytest.raises(
RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"
):
with pytest.raises(RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"):
client.retain(bank_id=bank_id, content="This should fail")
def test_embedded_ui_flag(llm_config):
"""
Test that ui=True starts the control plane UI alongside the daemon,
and that the UI's health endpoint reports a connected dataplane.
"""
profile = f"test_ui_{uuid.uuid4().hex[:8]}"
bank_id = f"bank_{uuid.uuid4().hex[:8]}"
client = HindsightEmbedded(profile=profile, log_level="info", ui=True, **llm_config)
try:
# First use triggers daemon + UI startup
result = client.retain(bank_id=bank_id, content="UI integration test content")
assert result.success, "Retain should succeed"
assert client.is_running, "Daemon should be running"
# Verify UI is reachable and reports connected dataplane
ui_url = client.ui_url
assert isinstance(ui_url, str) and ui_url, "ui_url should be a non-empty string"
health_url = f"{ui_url}/api/health"
with urllib.request.urlopen(health_url, timeout=10) as resp:
health = json.loads(resp.read().decode())
assert health["status"] == "ok", (
f"UI health status should be 'ok', got: {health['status']}"
)
assert health["dataplane"]["status"] == "connected", (
f"Dataplane should be connected, got: {health['dataplane']}"
)
finally:
client.close()
def test_embedded_daemon_crash_recovery(llm_config):
"""
Test that HindsightEmbedded recovers when the daemon crashes.
Simulates a crash by stopping the daemon, then verifies
that the next operation transparently restarts it.
"""
profile = f"test_crash_{uuid.uuid4().hex[:8]}"
bank_id = f"bank_{uuid.uuid4().hex[:8]}"
client = HindsightEmbedded(profile=profile, log_level="info", **llm_config)
try:
# Start daemon and store a memory
result = client.retain(bank_id=bank_id, content="Before crash")
assert result.success, "Initial retain should succeed"
assert client.is_running, "Daemon should be running"
original_url = client.url
# Simulate daemon crash by stopping it
client._manager.stop(client.profile)
assert not client._manager.is_running(client.profile), (
"Daemon should be stopped after simulated crash"
)
# Next operation should transparently restart the daemon
result2 = client.retain(bank_id=bank_id, content="After crash recovery")
assert result2.success, "Retain after crash recovery should succeed"
assert client.is_running, "Daemon should be running again after recovery"
# Verify recall still works
recall_result = client.recall(bank_id=bank_id, query="crash")
assert isinstance(recall_result.results, list), "Recall should return results"
finally:
client.close()
+2 -2
View File
@@ -99,7 +99,7 @@ hindsight-api
## Docker
```bash
docker run -it --name hindsight --restart unless-stopped -p 8888:8888 \
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
## Documentation
+1 -8
View File
@@ -4,13 +4,6 @@ Memory System for AI Agents.
Temporal + Semantic Memory Architecture using PostgreSQL with pgvector.
"""
# Cap native ML thread pools (OpenBLAS/OpenMP/MKL) before any import pulls in
# numpy/torch/onnxruntime — they read these env vars only at load time. See
# hindsight_api/_thread_limits.py for the rationale.
from ._thread_limits import apply_default_thread_limits
apply_default_thread_limits()
from .config import HindsightConfig, get_config
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
@@ -53,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.4"
__version__ = "0.4.19"
@@ -1,85 +0,0 @@
"""Helpers for ParadeDB pg_search index configuration."""
from __future__ import annotations
import re
from collections.abc import Sequence
PG_SEARCH_TOKENIZER_ENV = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER"
_SIMPLE_TOKENIZERS = {
"unicode_words",
"simple",
"whitespace",
"literal",
"literal_normalized",
"chinese_compatible",
"icu",
"jieba",
"source_code",
}
_TOKENIZER_ALIASES = {
"chinese_lindera": "lindera(chinese)",
"japanese_lindera": "lindera(japanese)",
"korean_lindera": "lindera(korean)",
"lindera_chinese": "lindera(chinese)",
"lindera_japanese": "lindera(japanese)",
"lindera_korean": "lindera(korean)",
}
def normalize_pg_search_tokenizer(value: str | None) -> str:
"""Validate and normalize a ParadeDB pg_search tokenizer setting.
Returns an empty string when unset. The returned value is safe to embed after
``pdb.`` in a CREATE INDEX expression.
"""
tokenizer = (value or "").strip().lower()
if not tokenizer:
return ""
if tokenizer in _TOKENIZER_ALIASES:
return _TOKENIZER_ALIASES[tokenizer]
if tokenizer in _SIMPLE_TOKENIZERS:
return tokenizer
lindera_match = re.fullmatch(r"lindera\((chinese|japanese|korean)\)", tokenizer)
if lindera_match:
return tokenizer
ngram_match = re.fullmatch(r"(ngram|edge_ngram)\((\d{1,3}),\s*(\d{1,3})\)", tokenizer)
if ngram_match:
kind, min_gram, max_gram = ngram_match.groups()
min_value = int(min_gram)
max_value = int(max_gram)
if min_value <= 0 or min_value > max_value:
raise ValueError(
f"Invalid {PG_SEARCH_TOKENIZER_ENV}: {value!r}. "
"ngram and edge_ngram require positive min/max gram sizes with min <= max."
)
return f"{kind}({min_value},{max_value})"
raise ValueError(
f"Invalid {PG_SEARCH_TOKENIZER_ENV}: {value!r}. "
"Supported values are: unicode_words, simple, whitespace, literal, "
"literal_normalized, chinese_compatible, icu, jieba, source_code, "
"chinese_lindera, japanese_lindera, korean_lindera, or "
"lindera(chinese|japanese|korean), ngram(min,max), or edge_ngram(min,max)."
)
def pg_search_bm25_columns(
key_field: str,
text_fields: Sequence[str],
tokenizer: str | None,
) -> str:
"""Build a ParadeDB BM25 column list for CREATE INDEX."""
normalized = normalize_pg_search_tokenizer(tokenizer)
if not normalized:
return ", ".join([key_field, *text_fields])
return ", ".join([key_field, *(f"({field}::pdb.{normalized})" for field in text_fields)])
@@ -1,107 +0,0 @@
"""Process-level caps for native ML thread pools.
OpenBLAS, OpenMP, and MKL each spawn a worker pool sized to the host CPU count
the first time they are loaded (numpy pulls in OpenBLAS eagerly; torch and
onnxruntime load their pools lazily on first inference). Hindsight already
parallelizes at the request level via thread-pool executors (embeddings on the
default executor, the reranker on its own pool), so these native intra-op pools
oversubscribe the CPU: on a many-core host the process accumulates 100+ native
threads, which inflates memory and, under contention, can degrade throughput.
We bound each pool to ``_MAX_NATIVE_THREADS`` (or the available CPU count, if
smaller). "Available" is the CPU budget actually granted to the process, not
``os.cpu_count()``: in a CPU-limited container ``os.cpu_count()`` still reports
the host's cores, so sizing pools by it oversubscribes the container's real
quota — the exact failure mode this guards against. We therefore take the
smallest of the CPU-affinity set, the cgroup CPU quota, and ``os.cpu_count()``.
Every cap is applied with ``setdefault`` so an operator who has deliberately
tuned one of these variables keeps their value. This must run *before* numpy,
torch, or onnxruntime are imported — those libraries read the variables only at
load time — which is why it is invoked at the very top of
``hindsight_api/__init__.py``, ahead of the package's other imports.
"""
from __future__ import annotations
import os
# Native threading env vars, each read by the respective library at load time.
_NATIVE_THREAD_VARS = (
"OMP_NUM_THREADS", # OpenMP — torch, onnxruntime, some BLAS builds
"OPENBLAS_NUM_THREADS", # OpenBLAS — numpy's default BLAS
"MKL_NUM_THREADS", # Intel MKL — numpy/torch when MKL-backed
"NUMEXPR_NUM_THREADS", # numexpr expression engine
)
# Upper bound on intra-op threads per native pool. Bounds runaway growth on
# many-core hosts without serialising single-request inference.
_MAX_NATIVE_THREADS = 16
def _quota_to_cpus(quota: int, period: int) -> int | None:
"""Whole CPUs from a CFS quota/period pair, or None if unlimited."""
if quota > 0 and period > 0:
# Floor (never round up) so we never exceed the granted budget.
return max(1, quota // period)
return None
def _parse_cgroup_v2_cpu_max(text: str) -> int | None:
"""Parse cgroup v2 ``cpu.max`` ("<quota> <period>", or "max <period>")."""
parts = text.split()
if len(parts) >= 2 and parts[0] != "max":
try:
return _quota_to_cpus(int(parts[0]), int(parts[1]))
except ValueError:
return None
return None
def _cgroup_cpu_quota() -> int | None:
"""Effective CPUs from the cgroup CPU quota, or None if unlimited/unknown."""
try: # cgroup v2
with open("/sys/fs/cgroup/cpu.max") as fh:
return _parse_cgroup_v2_cpu_max(fh.read())
except OSError:
pass
try: # cgroup v1
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as fh:
quota = int(fh.read())
with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as fh:
period = int(fh.read())
return _quota_to_cpus(quota, period)
except (OSError, ValueError):
return None
def _available_cpu_count() -> int:
"""CPUs actually available to this process.
The smallest of the CPU-affinity set (cpuset / ``--cpuset-cpus``), the
cgroup CPU quota (``--cpus``), and ``os.cpu_count()`` — each captures a
different way the budget can be constrained, and the last alone overcounts
inside a limited container.
"""
candidates = [os.cpu_count() or 1]
if hasattr(os, "sched_getaffinity"):
try:
candidates.append(len(os.sched_getaffinity(0)))
except OSError:
pass
quota = _cgroup_cpu_quota()
if quota is not None:
candidates.append(quota)
return max(1, min(candidates))
def default_native_thread_count() -> int:
"""Per-pool cap: ``_MAX_NATIVE_THREADS``, or available CPUs if fewer."""
return min(_MAX_NATIVE_THREADS, _available_cpu_count())
def apply_default_thread_limits() -> None:
"""Cap native ML thread pools unless the operator has set the var already."""
value = str(default_native_thread_count())
for var in _NATIVE_THREAD_VARS:
os.environ.setdefault(var, value)
@@ -1,225 +0,0 @@
"""Shared PostgreSQL vector-extension dispatch helpers."""
from __future__ import annotations
import logging
import os
from sqlalchemy import text
from sqlalchemy.engine import Connection
logger = logging.getLogger(__name__)
# Extensions a user can set via HINDSIGHT_API_VECTOR_EXTENSION.
CONFIGURABLE_EXTENSIONS = ("pgvector", "pgvectorscale", "vchord", "scann")
# Extensions detect_vector_extension() can return. pg_diskann is a runtime-only
# resolution from a configured "pgvectorscale" backend on Azure (uses a different
# WITH clause), never a value the user sets directly.
RESOLVED_EXTENSIONS = (*CONFIGURABLE_EXTENSIONS, "pg_diskann")
# Backwards-compatible alias for older imports.
VALID_EXTENSIONS = CONFIGURABLE_EXTENSIONS
SCANN_MIN_ROWS_FOR_AUTO_INDEX = 10_000
_EXTENSION_NAMES = {
"pgvector": "vector",
"pgvectorscale": "vectorscale",
"vchord": "vchord",
"scann": "alloydb_scann",
}
_INDEX_USING_CLAUSES = {
"pgvector": "USING hnsw (embedding vector_cosine_ops)",
"pgvectorscale": "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)",
"pg_diskann": "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)",
"vchord": "USING vchordrq (embedding vector_cosine_ops)",
"scann": "USING scann (embedding cosine) WITH (mode = 'AUTO')",
}
_INDEX_TYPE_KEYWORDS = {
"pgvector": "hnsw",
"pgvectorscale": "diskann",
"pg_diskann": "diskann",
"vchord": "vchordrq",
"scann": "scann",
}
# Per-backend ANN search-time tuning GUCs. Each entry is a tuple of
# (guc_name, value) pairs the caller can apply with SET or SET LOCAL.
#
# - pgvector exposes hnsw.ef_search. The 60 / 200 pair is unchanged from the
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
# and a single value can be invalid for listless or mixed-layout indexes.
# Hindsight's built-in vchord clause does not set lists, so the safe default
# is no session-level probe override; deployments that partition vchordrq
# indexes should attach probes to the index storage parameters instead.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
}
_EXTENSION_INSTALL_SQL = {
"pgvector": ("CREATE EXTENSION IF NOT EXISTS vector",),
"pgvectorscale": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE",
),
"vchord": ("CREATE EXTENSION IF NOT EXISTS vchord CASCADE",),
"scann": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
),
}
_INSTALL_HINTS = {
"pgvector": "CREATE EXTENSION vector;",
"pgvectorscale": "CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)",
"vchord": "CREATE EXTENSION vchord CASCADE;",
"scann": "CREATE EXTENSION vector; then CREATE EXTENSION alloydb_scann CASCADE;",
}
def configured_vector_extension() -> str:
"""Return the user-configured vector backend extension.
Reads ``HINDSIGHT_API_VECTOR_EXTENSION`` (default ``"pgvector"``) and
validates it via :func:`validate_extension`. This is the single source of
truth for runtime code that needs to dispatch behaviour by vector backend;
callers should prefer this over reading the env var directly, so the
default value and the lookup mechanism live in one place.
"""
return validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
def validate_extension(name: str) -> str:
"""Return a normalized configurable vector extension name or raise.
Used at the user-facing config boundary; pg_diskann is rejected here because
it is a detection-time alias, never a value the user sets directly.
"""
ext = name.lower()
if ext not in CONFIGURABLE_EXTENSIONS:
valid = ", ".join(CONFIGURABLE_EXTENSIONS)
raise ValueError(f"Invalid vector_extension: {name}. Must be one of: {valid}")
return ext
def _normalize_resolved(name: str) -> str:
"""Normalize either a user-configurable or detect-time extension name."""
ext = name.lower()
if ext not in RESOLVED_EXTENSIONS:
valid = ", ".join(RESOLVED_EXTENSIONS)
raise ValueError(f"Unknown vector extension: {name}. Must be one of: {valid}")
return ext
def pg_extension_name(ext: str) -> str:
"""Return the PostgreSQL extension name for a configured vector backend."""
return _EXTENSION_NAMES[validate_extension(ext)]
def index_using_clause(ext: str) -> str:
"""Return the CREATE INDEX USING clause for the vector backend."""
return _INDEX_USING_CLAUSES[_normalize_resolved(ext)]
def index_type_keyword(ext: str) -> str:
"""Return the keyword that identifies this index type in pg_indexes.indexdef."""
return _INDEX_TYPE_KEYWORDS[_normalize_resolved(ext)]
def minimum_rows_for_index(ext: str) -> int:
"""Return the minimum populated embedding rows before creating this index type."""
return SCANN_MIN_ROWS_FOR_AUTO_INDEX if _normalize_resolved(ext) == "scann" else 0
def should_defer_index_creation(ext: str, row_count: int) -> bool:
"""Return True when index creation should wait for more embeddings."""
minimum_rows = minimum_rows_for_index(ext)
return minimum_rows > 0 and row_count < minimum_rows
def ann_search_tuning_settings(ext: str, *, kind: str) -> tuple[tuple[str, str], ...]:
"""Return per-backend (guc_name, value) pairs for ANN search-time tuning.
``kind`` is ``"low_latency"`` for retain-side link probing (smaller probe
count, lower recall, lower latency) and ``"high_recall"`` for connection
init in the pool (larger probe count, higher recall). Callers wrap each
pair with ``SET LOCAL`` or ``SET`` themselves so the same dispatcher works
for both transaction-scoped and session-scoped use. Returns an empty tuple
for backends without an equivalent knob.
"""
if kind == "low_latency":
table = _ANN_TUNING_LOW_LATENCY
elif kind == "high_recall":
table = _ANN_TUNING_HIGH_RECALL
else:
raise ValueError(f"Unknown ANN tuning kind: {kind!r}")
return table.get(_normalize_resolved(ext), ())
def uses_per_bank_vector_indexes(ext: str) -> bool:
"""Return whether the backend should create per-bank partial vector indexes."""
return _normalize_resolved(ext) != "scann"
def bootstrap_extension(conn: Connection, ext: str) -> None:
"""Install the configured vector extension and any prerequisites if possible."""
normalized = validate_extension(ext)
for statement in _EXTENSION_INSTALL_SQL[normalized]:
conn.execute(text(statement))
def detect_vector_extension(conn: Connection, vector_extension: str = "pgvector") -> str:
"""Validate the configured vector extension exists and return the index backend."""
configured_ext = validate_extension(vector_extension)
if configured_ext == "pgvectorscale":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
f"Install it with: {_INSTALL_HINTS['pgvectorscale']}"
)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
return "pgvectorscale"
if pg_diskann_check:
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
return "pg_diskann"
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale (open source): CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
extension_name = pg_extension_name(configured_ext)
extension_check = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = :extension_name"),
{"extension_name": extension_name},
).scalar()
if not extension_check:
raise RuntimeError(
f"Configured vector extension '{configured_ext}' not found. "
f"Install it with: {_INSTALL_HINTS[configured_ext]}"
)
logger.debug("Using configured vector extension: %s", configured_ext)
return configured_ext
+41 -329
View File
@@ -1,7 +1,5 @@
"""PostgreSQL-only admin utilities (backup, restore, migration, worker management).
Not supported on Oracle backends. Uses asyncpg.connect() directly, binary COPY,
TRUNCATE CASCADE, and REFRESH MATERIALIZED VIEW — all inherently PG-specific.
"""
Hindsight Admin CLI - backup and restore operations.
"""
import asyncio
@@ -17,12 +15,15 @@ import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..engine.memory_engine import _current_schema
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
def _fq_table(table: str, schema: str) -> str:
"""Get fully-qualified table name with schema prefix."""
return f"{schema}.{table}"
# Setup logging
logging.basicConfig(
level=logging.INFO,
@@ -32,59 +33,22 @@ logger = logging.getLogger(__name__)
app = typer.Typer(name="hindsight-admin", help="Hindsight administrative commands")
# Tables to backup/restore in foreign-key dependency order (parents first).
# Restore COPYs in this order and TRUNCATEs in reverse, so every child must
# appear after the tables it references.
#
# This must cover EVERY persistent PostgreSQL table in the schema — a missing
# entry silently drops that table's data on restore (and, worse, restore's
# `TRUNCATE banks CASCADE` wipes any FK-to-banks child like mental_models even
# when it was never backed up). test_admin_backup_restore.py asserts this list
# equals the live schema's tables, so adding a migration that creates a table
# without adding it here fails CI. Oracle-only tables (e.g. observation_sources)
# are intentionally absent — admin backup/restore is PostgreSQL-only.
# Tables to backup/restore in dependency order
# Import must happen in this order due to foreign key constraints
BACKUP_TABLES = [
"banks",
"documents",
"entities",
"chunks",
"memory_units",
"invalidated_memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"directives",
"async_operations",
"webhooks",
"file_storage",
"audit_log",
"llm_requests",
"graph_maintenance_queue",
]
MANIFEST_VERSION = "1"
async def _admin_connect(db_url: str) -> asyncpg.Connection:
"""Open a raw asyncpg connection to an admin DB URL.
``resolve_database_url`` handles both plain ``postgres://`` (passthrough) and
``pg0://`` (boots the embedded server and returns its real libpq URL), so this
is the only step needed to connect. JSON codecs are registered so ``jsonb``
columns decode to Python objects (used by the export row dumps).
"""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
conn = await asyncpg.connect(await resolve_database_url(db_url))
for type_name in ("json", "jsonb"):
await conn.set_type_codec(type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog")
return conn
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol."""
conn = await asyncpg.connect(database_url)
@@ -256,10 +220,14 @@ async def _run_migration(
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
ensure_extensions: bool = True,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import run_migrations_for_schemas
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
@@ -280,21 +248,31 @@ async def _run_migration(
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
# Migrate up to `migration_concurrency` schemas at once (each in its own
# process); within a schema the work stays sequential. Run off the event
# loop so the process pool's blocking joins don't stall it.
await asyncio.to_thread(
run_migrations_for_schemas,
resolved_url,
schemas,
concurrency=config.migration_concurrency,
migration_database_url=config.migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
for schema in schemas:
run_migrations(resolved_url, schema=schema)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
schema=schema,
)
return schemas
@@ -312,18 +290,6 @@ def run_db_migration(
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
skip_extension_reconcile: bool = typer.Option(
False,
"--skip-extension-reconcile",
help=(
"Skip the post-migration vector / text-search index reconcile. This step only does "
"work when the configured backend (HINDSIGHT_API_VECTOR_EXTENSION / "
"HINDSIGHT_API_TEXT_SEARCH_EXTENSION) differs from a schema's existing indexes — a "
"rare, operator-driven change. Skipping it makes a no-change re-migration over many "
"tenant schemas much faster. Only use when you have NOT changed the backend; a "
"backend change still needs a normal run to reshape the indexes."
),
),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -337,8 +303,6 @@ def run_db_migration(
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
if skip_extension_reconcile:
typer.echo("Skipping post-migration extension reconcile (--skip-extension-reconcile).")
schemas = asyncio.run(
_run_migration(
@@ -346,130 +310,12 @@ def run_db_migration(
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
ensure_extensions=not skip_extension_reconcile,
)
)
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str, include_history: bool) -> int:
"""Export a whole bank to a ZIP archive."""
conn = await _admin_connect(db_url)
try:
# export_bank resolves table names via fq_table (the _current_schema
# contextvar); set it so the raw connection targets the right schema.
_current_schema.set(schema)
data = await export_bank(conn, bank_id, include_history=include_history)
finally:
await conn.close()
output.write_bytes(data)
return len(data)
@app.command(name="export-bank")
def export_bank_command(
bank_id: str = typer.Option(..., "--bank", "-b", help="Bank id to export."),
output: Path = typer.Option(..., "--output", "-o", help="Path to write the .zip archive."),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Database schema the bank lives in. Defaults to the configured base schema.",
),
include_history: bool = typer.Option(
False,
"--include-history",
help="Also export operational history (audit_log, llm_requests). Off by default.",
),
):
"""Export an entire bank to a portable ZIP (no embeddings — regenerated on import).
Carries documents, facts, observations, bank config, mental models, directives
and webhooks so the bank can be imported into a new instance configured with a
different embedding model / vector / text-search backend.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Exporting bank '{bank_id}' from schema '{target_schema}'...")
size = asyncio.run(_run_export_bank(config.database_url, bank_id, output, target_schema, include_history))
typer.echo(f"Exported bank '{bank_id}' to {output} ({size} bytes)")
async def _run_import_bank(archive_path: Path, schema: str, target_bank_id: str | None, include_history: bool):
"""Boot a MemoryEngine (for the target's embedding model) and restore a bank archive."""
# MemoryEngine is heavy (loads embeddings); import it lazily so other admin
# commands don't pay for it. _current_schema is imported at module top.
from ..engine.memory_engine import MemoryEngine
from ..models import RequestContext
archive_bytes = archive_path.read_bytes()
# run_migrations=True so a fresh target instance is provisioned at this
# instance's embedding dimension / vector / text-search backend before restore.
engine = MemoryEngine(run_migrations=True)
await engine.initialize()
try:
_current_schema.set(schema)
context = RequestContext(internal=True, user_initiated=True)
return await engine.import_bank_async(
archive_bytes,
context,
target_bank_id=target_bank_id,
include_history=include_history,
)
finally:
await engine.close()
@app.command(name="import-bank")
def import_bank_command(
archive: Path = typer.Option(..., "--archive", "-a", help="Path to the .zip produced by export-bank."),
schema: str | None = typer.Option(
None, "--schema", "-s", help="Target schema. Defaults to the configured base schema."
),
target_bank: str | None = typer.Option(
None, "--target-bank", help="Override the bank id (defaults to the archive's source bank)."
),
include_history: bool = typer.Option(
False, "--include-history", help="Also restore operational history if present in the archive."
),
):
"""Restore a whole bank from an export-bank archive into THIS instance.
Re-embeds facts with this instance's configured embedding model and rebuilds
links and indexes — the import half of a cross-instance migration. Run against
an instance configured with the desired embedding / vector / text-search backend.
The target bank must not already exist (import restores a whole bank, not a merge).
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Importing bank archive '{archive}' into schema '{target_schema}'...")
result = asyncio.run(_run_import_bank(archive, target_schema, target_bank, include_history))
typer.echo(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
"""Release all tasks owned by a worker, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
@@ -529,140 +375,6 @@ def decommission_worker(
typer.echo(f"No tasks found for worker '{worker_id}'")
async def _decommission_all_workers(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Release all processing tasks from all workers, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
conn = await asyncpg.connect(resolved_url)
try:
table = _fq_table("async_operations", schema)
rows = await conn.fetch(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing'
RETURNING operation_id, worker_id, operation_type
""",
)
return [dict(r) for r in rows]
finally:
await conn.close()
@app.command(name="decommission-workers")
def decommission_workers(
schema: str = typer.Option("public", "--schema", "-s", help="Database schema"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Release all processing tasks from all workers (sets status back to pending).
Use this command to recover from situations where one or more workers have crashed
or been removed without graceful shutdown. All tasks currently in 'processing' status
will be released back to the queue regardless of which worker owns them.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
if not yes:
typer.confirm(
"This will release ALL processing tasks from ALL workers back to pending. Continue?",
abort=True,
)
typer.echo(f"Decommissioning all workers (schema: {schema})...")
released = asyncio.run(_decommission_all_workers(config.database_url, schema))
if released:
# Group by worker_id for summary
by_worker: dict[str, int] = {}
for row in released:
wid = row["worker_id"] or "unknown"
by_worker[wid] = by_worker.get(wid, 0) + 1
typer.echo(f"Released {len(released)} task(s):")
for wid, count in by_worker.items():
typer.echo(f" {wid}: {count} task(s)")
else:
typer.echo("No processing tasks found")
async def _worker_status(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Get all processing tasks grouped by worker with their last update time."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
conn = await asyncpg.connect(resolved_url)
try:
table = _fq_table("async_operations", schema)
rows = await conn.fetch(
f"""
SELECT worker_id, operation_id, operation_type, bank_id,
claimed_at, updated_at,
now() - claimed_at AS running_for,
now() - updated_at AS last_update_ago
FROM {table}
WHERE status = 'processing'
ORDER BY worker_id, claimed_at
""",
)
return [dict(r) for r in rows]
finally:
await conn.close()
@app.command(name="worker-status")
def worker_status(
schema: str = typer.Option("public", "--schema", "-s", help="Database schema"),
):
"""Show all currently processing tasks grouped by worker.
Displays each worker's active tasks with operation type, bank, how long
the task has been running, and when it was last updated. Useful for
identifying dead workers with orphaned tasks.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
rows = asyncio.run(_worker_status(config.database_url, schema))
if not rows:
typer.echo("No processing tasks found")
return
# Group by worker_id
by_worker: dict[str, list[dict[str, Any]]] = {}
for row in rows:
wid = row["worker_id"] or "unknown"
by_worker.setdefault(wid, []).append(row)
typer.echo(f"Processing tasks across {len(by_worker)} worker(s):\n")
for wid, tasks in by_worker.items():
typer.echo(f"Worker: {wid} ({len(tasks)} task(s))")
for task in tasks:
op_id = str(task["operation_id"])[:8]
running_for = task["running_for"]
last_update = task["last_update_ago"]
typer.echo(
f" {op_id} {task['operation_type']:<20s} bank={task['bank_id']}"
f" running={running_for} last_update={last_update} ago"
)
typer.echo("")
def main():
app()
@@ -1,42 +0,0 @@
"""Dialect dispatcher for Alembic migrations.
Each migration file declares a ``_pg_upgrade``/``_oracle_upgrade`` (and matching
downgrades) function and routes ``upgrade()``/``downgrade()`` through
``run_for_dialect``. The helper inspects the live connection's dialect name and
runs the matching function — or no-ops if the migration doesn't apply to the
current backend.
Use ``None`` (or omit the kwarg) when a migration intentionally has no effect
on a dialect; the helper treats it as a no-op.
"""
from __future__ import annotations
from collections.abc import Callable
from alembic import op
DialectFn = Callable[[], None]
_SUPPORTED = ("postgresql", "oracle")
def run_for_dialect(
*,
pg: DialectFn | None = None,
oracle: DialectFn | None = None,
) -> None:
"""Dispatch to the function matching the current bind's dialect.
Args:
pg: Function to run when the active bind is PostgreSQL.
oracle: Function to run when the active bind is Oracle.
Unrecognized dialects raise; an explicit ``None`` for the active dialect
is a no-op (the migration deliberately does nothing here).
"""
name = op.get_bind().dialect.name
if name not in _SUPPORTED:
raise RuntimeError(f"Unsupported dialect for migration dispatch: {name!r}. Expected one of {_SUPPORTED}.")
fn = {"postgresql": pg, "oracle": oracle}[name]
if fn is not None:
fn()
+76 -101
View File
@@ -1,37 +1,28 @@
"""
Alembic environment for Hindsight.
Supports two dialects:
* PostgreSQL (sync psycopg2 driver) — default; uses ``search_path`` for
multi-tenant schema isolation and forces read-write transactions to work
around Supabase's read-only-by-default sessions.
* Oracle 23ai (``oracledb`` driver) — uses ``CURRENT_SCHEMA`` for tenant
isolation; no equivalent of ``search_path`` or read-only session quirks.
Each migration file dispatches its DDL through ``alembic._dialect.run_for_dialect``
so a single revision tree serves both backends.
Alembic environment configuration for SQLAlchemy with pgvector.
Uses synchronous psycopg2 driver for migrations to avoid pgbouncer issues.
"""
import logging
import os
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import Connection, engine_from_config, pool
from sqlalchemy.engine import Engine
from sqlalchemy import engine_from_config, pool
from hindsight_api.db_url import is_oracle_url, to_libpq_url
# Import your models here
from hindsight_api.models import Base
def load_env() -> None:
"""Load environment variables from .env (skipped if already configured)."""
# Load environment variables based on HINDSIGHT_API_DATABASE_URL env var or default to local
def load_env():
"""Load environment variables from .env"""
# Check if HINDSIGHT_API_DATABASE_URL is already set (e.g., by CI/CD)
if os.getenv("HINDSIGHT_API_DATABASE_URL"):
return
# Look for .env file in the parent directory (root of the workspace)
root_dir = Path(__file__).parent.parent.parent
env_file = root_dir / ".env"
@@ -41,45 +32,30 @@ def load_env() -> None:
load_env()
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Note: We don't call fileConfig() here to avoid overriding the application's logging configuration.
# Alembic will use the existing logging configuration from the application.
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def _normalize_oracle_url(url: str) -> str:
"""Coerce an Oracle URL into the SQLAlchemy form the oracledb dialect expects.
Two issues to handle:
1. Force the ``oracle+oracledb`` driver — bare ``oracle://`` defaults to
cx_Oracle.
2. Map a path-style service to ``?service_name=...``. SQLAlchemy's oracledb
dialect treats the URL path as a *SID* (legacy), but Oracle Free /
Autonomous DB only register a service name. Without this rewrite we get
``DPY-6003: SID "FREEPDB1" is not registered`` even though the listener
is happy to accept the same name as a service.
"""
parts = urlsplit(url)
if not parts.scheme.startswith("oracle"):
return url
new_scheme = "oracle+oracledb" if parts.scheme == "oracle" else parts.scheme
service = parts.path.lstrip("/")
new_query = parts.query
new_path = parts.path
# Promote /SERVICE to ?service_name=SERVICE unless the caller already
# supplied an explicit ?sid= or ?service_name=.
if service and "service_name=" not in new_query and "sid=" not in new_query:
params = [(k, v) for k, v in parse_qsl(new_query, keep_blank_values=True)]
params.append(("service_name", service))
new_query = urlencode(params)
new_path = ""
return urlunsplit((new_scheme, parts.netloc, new_path, new_query, parts.fragment))
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_database_url() -> str:
"""Resolve the migration URL from Alembic config or env, normalizing per-dialect."""
"""
Get and process the database URL from config or environment.
Returns the URL with the correct driver (psycopg2) for migrations.
"""
# Get database URL from config (set programmatically) or environment
database_url = config.get_main_option("sqlalchemy.url")
if not database_url:
database_url = os.getenv("HINDSIGHT_API_DATABASE_URL")
@@ -89,18 +65,30 @@ def get_database_url() -> str:
"Set HINDSIGHT_API_DATABASE_URL environment variable or pass database_url to run_migrations()."
)
if is_oracle_url(database_url):
database_url = _normalize_oracle_url(database_url)
else:
# PG: convert SQLAlchemy-style asyncpg URLs and ?ssl= params to libpq form
# for the sync engine used during migrations.
database_url = to_libpq_url(database_url)
# For migrations, use psycopg2 (sync driver) to avoid pgbouncer prepared statement issues
if database_url.startswith("postgresql+asyncpg://"):
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
elif database_url.startswith("postgres+asyncpg://"):
database_url = database_url.replace("postgres+asyncpg://", "postgresql://", 1)
# Update config with processed URL for engine_from_config to use
config.set_main_option("sqlalchemy.url", database_url)
return database_url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
logging.info("running offline")
database_url = get_database_url()
@@ -115,40 +103,14 @@ def run_migrations_offline() -> None:
context.run_migrations()
def _configure_pg_session(engine: Engine, connection: Connection, target_schema: str | None) -> None:
"""PG-only: ensure the session is RW (Supabase) and bind ``search_path``."""
def run_migrations_online() -> None:
"""Run migrations in 'online' mode with synchronous engine."""
from sqlalchemy import event, text
@event.listens_for(engine, "connect")
def set_read_write_mode(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
if target_schema:
cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"')
cursor.execute(f'SET search_path TO "{target_schema}", public')
cursor.close()
get_database_url() # Process and set the database URL in config
connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE"))
if target_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
connection.execute(text(f'SET search_path TO "{target_schema}", public'))
connection.commit()
def _configure_oracle_session(connection: Connection, target_schema: str | None) -> None:
"""Oracle: switch the session's default schema; tolerate DDL contention."""
from sqlalchemy import text
# Wait up to 30s for DDL locks instead of failing immediately (ORA-00054).
connection.execute(text("ALTER SESSION SET DDL_LOCK_TIMEOUT = 30"))
if target_schema:
connection.execute(text(f'ALTER SESSION SET CURRENT_SCHEMA = "{target_schema}"'))
def run_migrations_online() -> None:
database_url = get_database_url()
# Check if we're targeting a specific schema (for multi-tenant isolation)
target_schema = config.get_main_option("target_schema")
is_oracle = is_oracle_url(database_url)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
@@ -156,19 +118,37 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
if is_oracle:
_configure_oracle_session(connection, target_schema)
else:
_configure_pg_session(connectable, connection, target_schema)
# Add event listener to ensure connection is in read-write mode
# This is needed for Supabase which may start connections in read-only mode
@event.listens_for(connectable, "connect")
def set_read_write_mode(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"')
cursor.execute(f'SET search_path TO "{target_schema}", public')
cursor.close()
with connectable.connect() as connection:
# Also explicitly set read-write mode on this connection
connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE"))
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
connection.execute(text(f'SET search_path TO "{target_schema}", public'))
connection.commit() # Commit the SET command
# Configure context with version_table_schema if using a specific schema
context_opts = {
"connection": connection,
"target_metadata": target_metadata,
}
if target_schema and not is_oracle:
# Oracle has no equivalent of PG's per-schema version table; the
# ``alembic_version`` table lives in CURRENT_SCHEMA implicitly.
if target_schema:
context_opts["version_table_schema"] = target_schema
context.configure(**context_opts)
@@ -176,12 +156,7 @@ def run_migrations_online() -> None:
with context.begin_transaction():
context.run_migrations()
# Always commit. PG needs it for the explicit RW-mode SET to persist;
# Oracle needs it because each DDL auto-commits but the trailing
# ``UPDATE alembic_version`` is plain DML that would otherwise stay in
# an open transaction and roll back when the connection closes —
# producing the "schema is created but the version row is one revision
# behind" failure mode.
# Explicit commit to ensure changes are persisted (especially for Supabase)
connection.commit()
@@ -11,8 +11,6 @@ from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
from hindsight_api.alembic._dialect import run_for_dialect
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
@@ -20,27 +18,11 @@ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def _pg_upgrade() -> None:
"""PostgreSQL upgrade. Set to ``None`` below if this migration is Oracle-only."""
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def _pg_downgrade() -> None:
${downgrades if downgrades else "pass"}
def _oracle_upgrade() -> None:
"""Oracle upgrade. Set to ``None`` below if this migration is Postgres-only."""
pass
def _oracle_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -1,105 +0,0 @@
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
banks with millions of links. The column landed without an index, so every
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
table.
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
than ``bank_id`` alone because the hot query is the stats endpoint's
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
``(bank_id, link_type)`` index serves that filter, grouping and count as an
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
still have to read every matching row to recover ``link_type``. ``link_type`` is
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
little to the index size while removing the heap fetch.
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
``memory_links(bank_id)``; that single-column index already covers Oracle's
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
PostgreSQL dialect gets the composite index.
``memory_links`` can hold tens of millions of rows, so the index is built
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
inside a transaction block, so the statement runs in an ``autocommit_block()``;
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
then skip over it forever, so the upgrade first drops any invalid leftover of
this name before (re)creating it.
Revision ID: 2071c7518f88
Revises: a1d3f5b7c9e2
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2071c7518f88"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
bind = op.get_bind()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
# that relation and skip, so bank_id queries would keep seq-scanning.
# Drop only the invalid leftover — never a healthy index — so the retry
# actually rebuilds a usable one.
leftover_invalid = bind.execute(
text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _INDEX_NAME, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,55 +0,0 @@
"""Recreate entities trigram index on LOWER(canonical_name) for case-insensitive matching
The previous GIN trigram index on canonical_name was case-sensitive, causing
"Alice" and "alice" to have different trigram sets. This recreates it on
LOWER(canonical_name) so the % operator matches case-insensitively.
Revision ID: 2eee35aa3cfc
Revises: d6e7f8a9b0c1
Create Date: 2026-03-31
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2eee35aa3cfc"
down_revision: str | Sequence[str] | None = "d6e7f8a9b0c1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Drop the old case-sensitive trigram index
op.execute("DROP INDEX IF EXISTS entities_canonical_name_trgm_idx")
# Create case-insensitive trigram index on LOWER(canonical_name)
op.execute(
f"CREATE INDEX IF NOT EXISTS entities_canonical_name_lower_trgm_idx "
f"ON {schema}entities USING GIN (LOWER(canonical_name) gin_trgm_ops)"
)
def _pg_downgrade() -> None:
op.execute("DROP INDEX IF EXISTS entities_canonical_name_lower_trgm_idx")
schema = _get_schema_prefix()
# Restore original case-sensitive index
op.execute(
f"CREATE INDEX IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -15,13 +15,6 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
from hindsight_api._pg_search import (
PG_SEARCH_TOKENIZER_ENV,
normalize_pg_search_tokenizer,
pg_search_bm25_columns,
)
from hindsight_api.alembic._dialect import run_for_dialect
# revision identifiers, used by Alembic.
revision: str = "5a366d414dce"
down_revision: str | Sequence[str] | None = None
@@ -31,79 +24,59 @@ depends_on: str | Sequence[str] | None = None
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension for this immutable migration revision.
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
if pg_diskann_check:
elif pg_diskann_check:
return "pg_diskann"
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
if vector_extension == "vchord":
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
if vector_extension == "scann":
scann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'alloydb_scann'")).scalar()
if not scann_check:
raise RuntimeError(
"Configured vector extension 'scann' not found. Install it with: CREATE EXTENSION alloydb_scann CASCADE;"
)
return "scann"
if vector_extension == "pgvector":
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
raise ValueError(
"Invalid HINDSIGHT_API_VECTOR_EXTENSION: "
f"{vector_extension}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "pg_diskann":
return "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_cosine_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
"""
Detect or validate text search extension: 'native', 'vchord', 'pg_textsearch',
'pgroonga', or 'pg_search'. Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Creates the extension if needed.
pgroonga is treated as native here so the initial schema still creates valid
tsvector columns. ensure_text_search_extension() at startup converts the
schema to pgroonga structures (drops the tsvector column, builds a pgroonga
index on the base text column).
"""
text_search_extension = os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
@@ -131,36 +104,15 @@ def _detect_text_search_extension() -> str:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_textsearch"
elif text_search_extension == "pg_search":
# ParadeDB pg_search — true BM25 over base columns, Citus-compatible.
try:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_search CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_search'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_search"
elif text_search_extension == "native":
return "native"
elif text_search_extension == "pgroonga":
# ensure_text_search_extension() at runtime converts to pgroonga.
# Treat as native here so the initial schema still creates valid columns.
return "native"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. "
"Must be 'native', 'vchord', 'pg_textsearch', 'pgroonga', or 'pg_search'"
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
)
def _pg_search_tokenizer() -> str:
return normalize_pg_search_tokenizer(os.getenv(PG_SEARCH_TOKENIZER_ENV))
def _pg_upgrade() -> None:
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
# Note: pgvector extension is installed globally BEFORE migrations run
@@ -315,9 +267,8 @@ def _pg_upgrade() -> None:
ALTER TABLE memory_units
ADD COLUMN search_vector bm25_catalog.bm25vector
""")
elif text_search_ext in ("pg_textsearch", "pg_search"):
# Timescale pg_textsearch / ParadeDB pg_search: dummy TEXT column for
# consistency (indexes operate on base columns directly).
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector TEXT
@@ -360,11 +311,36 @@ def _pg_upgrade() -> None:
)
# Create vector index - conditional based on available extension
vector_ext = _detect_vector_extension()
if vector_ext != "scann":
op.execute(f"""
if vector_ext == "pgvectorscale":
# Use DiskANN index for pgvectorscale (disk-based, scalable)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
{_vector_index_using_clause(vector_ext)}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
# Use DiskANN index for pg_diskann (Azure)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
# Use vchordrq index for vchord (supports high-dimensional embeddings)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
# Use HNSW index for pgvector
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create full-text search index on search_vector
# Index type depends on text search backend
@@ -382,17 +358,6 @@ def _pg_upgrade() -> None:
USING bm25(text)
WITH (text_config='english')
""")
elif text_search_ext == "pg_search":
# ParadeDB pg_search BM25 index on (id, text, context). The key_field
# reloption is required and must match the table's primary key column.
bm25_cols = pg_search_bm25_columns("id", ("text", "context"), _pg_search_tokenizer())
op.execute(
"""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING bm25 ({bm25_cols})
WITH (key_field='id')
""".format(bm25_cols=bm25_cols)
)
else: # native
# Native PostgreSQL GIN index
op.execute("""
@@ -498,7 +463,7 @@ def _pg_upgrade() -> None:
op.create_index("idx_unit_entities_entity", "unit_entities", ["entity_id"])
def _pg_downgrade() -> None:
def downgrade() -> None:
"""Downgrade schema - drop all tables."""
# Drop tables in reverse dependency order
@@ -558,11 +523,3 @@ def _pg_downgrade() -> None:
# Drop extensions (optional - comment out if you want to keep them)
# op.execute('DROP EXTENSION IF EXISTS vector')
# op.execute('DROP EXTENSION IF EXISTS "uuid-ossp"')
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,117 +0,0 @@
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
Three production deployments reported `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model` even after their
container reported `Database migrations completed successfully` and
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
confirmations from @4Lienau and @khanhduyvt0101).
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
were meant to ensure `subtype` exists, but on databases that came through the
`reflections -> mental_models` rename chain *and* whose alembic_version
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
reorganization, neither column-add actually fired. The result is a head-tagged
database with a v3-shaped `mental_models` table missing six columns:
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
``last_updated``.
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
deployment will pick it up on next container start. It mirrors the column-add
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
are already present.
Revision ID: 86f7a033d372
Revises: m3rg3h3ad5f6
Create Date: 2026-05-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "86f7a033d372"
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
"""Idempotently ensure mental_models has the v4 column set.
Safe to re-apply on databases that already received the columns via
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
from scratch with the canonical v4 allowlist.
"""
schema = _pg_schema_prefix()
bare_schema = schema.strip(".").strip('"') if schema else ""
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
# Wrapped in a DO block so the existence check skips databases that
# predate the reflections -> mental_models rename chain (no table to
# repair). On those, every ALTER below would error.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'mental_models'
{schema_clause}
) THEN
-- Add the six v4 columns idempotently.
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS links VARCHAR[];
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
-- Recreate the CHECK constraint with the canonical v4 allowlist.
-- Existing rows with subtype = 'directive' (possible on databases
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
-- 'structural' first so the constraint add succeeds.
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
ON {schema}mental_models(bank_id, subtype);
END IF;
END$$;
"""
)
def _pg_downgrade() -> None:
"""No-op: dropping these columns would corrupt v4 application code."""
pass
def upgrade() -> None:
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
# different table topology, so this PG-shaped repair does not apply.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,50 +0,0 @@
"""Merge divergent migration heads for v0.5.3
v0.5.3 shipped with two migration heads that were never unified:
* ``c4x5y6z7a8b9`` — delta-refresh chain
(``add_last_refreshed_source_query`` ->
``add_structured_content_to_mental_models`` ->
``backsweep_orphan_observations_v2``)
* ``h3i4j5k6l7m8`` — per-bank vector indexes / audit log chain
(the ``merge_heads_and_add_unit_entities_index`` subtree)
Both fork from ``z1u2v3w4x5y6``. Upgrades from v0.5.2 still succeed — the
walker applies the three c4x5 revisions and leaves the database stamped at
both heads — but the result is a split DAG: ``alembic upgrade head``
(singular) is ambiguous, and any future migration has to pick one head as
its parent, orphaning the other.
This revision linearises the DAG into a single head. It has no schema
effect.
Revision ID: 8c6fa6f7230b
Revises: c4x5y6z7a8b9, h3i4j5k6l7m8
Create Date: 2026-04-18
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "8c6fa6f7230b"
down_revision: str | Sequence[str] | None = ("c4x5y6z7a8b9", "h3i4j5k6l7m8")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,128 +0,0 @@
"""Make memory_links.from_unit_id and memory_links.to_unit_id FKs deferrable.
Revision ID: 9f8e7d6c5b4a
Revises: o1a2b3c4d5e6
Create Date: 2026-05-03
Background
----------
Concurrent retain (which INSERTs into ``memory_links``) and any code path
that DELETEs a row whose deletion cascades into ``memory_links`` (e.g.
delta-retain superseding chunks, which CASCADEs chunks → memory_units →
memory_links) can deadlock under sustained single-tenant write load.
The deadlock cycle:
* Tx A: ``DELETE FROM chunks WHERE chunk_id = ANY(...)``
→ CASCADE acquires row locks on memory_units, then on memory_links rows
where ``to_unit_id`` matches the deleted units.
* Tx B: ``INSERT INTO memory_links (...)`` referencing one of the same
memory_units rows.
→ The immediate FK check takes ``FOR KEY SHARE`` on those memory_units
rows.
The two transactions take row locks on the same memory_units rows in
opposite orders depending on which side started first. PostgreSQL detects
the cycle and aborts one transaction; the loser is killed mid-batch, the
winner continues. Workers then retry, but under sustained write load the
pattern repeats.
Fix
---
Make both ``memory_links → memory_units`` FKs (``from_unit_id`` and
``to_unit_id``) ``DEFERRABLE INITIALLY DEFERRED``. This pushes the FK
check from INSERT time to COMMIT time:
* INSERT no longer takes ``FOR KEY SHARE`` on the memory_units row → no
contention with the cascading DELETE's row lock.
* At COMMIT the engine validates referential integrity in one shot. If a
cascade-DELETE has since removed the referenced unit, the INSERT
transaction commits OR fails with a clean FK violation (sqlstate
23503) instead of a deadlock (sqlstate 40P01).
The ``WHERE EXISTS`` filter already in ``_bulk_insert_links`` continues to
filter out the typical "stale unit_id" case at INSERT time; the deferred
FK is only the backstop for the narrow race window between the EXISTS
probe and COMMIT. ``ON DELETE CASCADE`` semantics are unchanged — only
the *timing* of the constraint check moves.
The ``entity_id`` FK on ``memory_links`` is not changed; entities are not
involved in the observed deadlock cycle and leaving the constraint
immediate keeps the error message specific when an entity row is missing.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "9f8e7d6c5b4a"
down_revision: str | Sequence[str] | None = "o1a2b3c4d5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
# The two FK constraints installed by the initial schema migration
# (5a366d414dce_initial_schema), mapped to the column they constrain.
# They reference memory_units(id) with ON DELETE CASCADE — that
# semantics is preserved; only the deferral attribute changes.
_FK_COLUMNS: dict[str, str] = {
"fk_memory_links_from_unit_id_memory_units": "from_unit_id",
"fk_memory_links_to_unit_id_memory_units": "to_unit_id",
}
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# PostgreSQL doesn't allow altering the deferrability of an existing
# constraint with ALTER CONSTRAINT — the constraint must be dropped
# and recreated. DROP IF EXISTS makes the migration safe to re-run
# on schemas where the constraint was already recreated.
for fk_name, column in _FK_COLUMNS.items():
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS {fk_name}")
op.execute(
f"""
ALTER TABLE {schema}memory_links
ADD CONSTRAINT {fk_name}
FOREIGN KEY ({column})
REFERENCES {schema}memory_units (id)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
# Revert to the default (NOT DEFERRABLE) form so a downgrade actually
# restores the prior schema state, even though that re-introduces the
# deadlock window.
for fk_name, column in _FK_COLUMNS.items():
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS {fk_name}")
op.execute(
f"""
ALTER TABLE {schema}memory_links
ADD CONSTRAINT {fk_name}
FOREIGN KEY ({column})
REFERENCES {schema}memory_units (id)
ON DELETE CASCADE
"""
)
def upgrade() -> None:
# PG-only: Oracle's deferrable-FK semantics differ and the deadlock
# cycle was only observed on PostgreSQL. Oracle slot intentionally
# absent → no-op there.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -15,8 +15,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1b2c3d4e5f6"
down_revision: str | Sequence[str] | None = "y0t1u2v3w4x5"
branch_labels: str | Sequence[str] | None = None
@@ -29,7 +27,7 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
def upgrade() -> None:
"""Create file_storage table for BYTEA storage."""
schema = _get_schema_prefix()
@@ -54,7 +52,7 @@ def _pg_upgrade() -> None:
)
def _pg_downgrade() -> None:
def downgrade() -> None:
"""Remove file_storage table and related columns."""
schema = _get_schema_prefix()
@@ -70,11 +68,3 @@ def _pg_downgrade() -> None:
# Drop file_storage table
op.execute(f"DROP TABLE IF EXISTS {schema}file_storage")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,85 +0,0 @@
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
inconsistency still affects the live tables that store a user-supplied
``bank_id``:
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
``mental_model_versions`` is intentionally *not* widened here: it is created in
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
never recreated on the upgrade path, so it does not exist at head. Issuing
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
because migrations run inside the lifespan-startup transaction -- roll the whole
migration back, bricking the API. (It is unrelated to the live
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
issue #2106 -- and the bank insert succeeds. The next write that propagates that
id (``create_directive``, ``create_mental_model`` / consolidation, or
mental-model versioning) then aborts with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
i.e. a 500 on core write endpoints, instead of the startup brick that
``c3e5a7b9d1f4`` already repaired.
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
live in each tenant schema, not ``public``), so this runs for every migrated
schema via the search-path-aware prefix -- the same mechanism as
``c3e5a7b9d1f4``.
PostgreSQL only: these tables are created by PostgreSQL-only migrations
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
Revision ID: a1d3f5b7c9e2
Revises: c3e5a7b9d1f4
Create Date: 2026-06-13
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1d3f5b7c9e2"
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column types are owned by
# the migrations that created the tables.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -7,7 +7,6 @@ the stored fact text.
- vchord: text_signals included in tokenize() at insert time
- native: search_vector GENERATED column regenerated to include text_signals
- pg_textsearch: no change (index only supports a single base column)
- pg_search: BM25 index dropped and recreated to include text_signals
Revision ID: a2b3c4d5e6f7
Revises: z1u2v3w4x5y6
@@ -19,13 +18,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api._pg_search import (
PG_SEARCH_TOKENIZER_ENV,
normalize_pg_search_tokenizer,
pg_search_bm25_columns,
)
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "aa2b3c4d5e6f"
branch_labels: str | Sequence[str] | None = None
@@ -41,11 +33,7 @@ def _detect_text_search_extension() -> str:
return os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
def _pg_search_tokenizer() -> str:
return normalize_pg_search_tokenizer(os.getenv(PG_SEARCH_TOKENIZER_ENV))
def _pg_upgrade() -> None:
def upgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
@@ -72,22 +60,12 @@ def _pg_upgrade() -> None:
CREATE INDEX IF NOT EXISTS idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
elif text_search_ext == "pg_search":
# ParadeDB pg_search: drop the existing BM25 index and recreate it
# to include text_signals alongside text and context.
bm25_cols = pg_search_bm25_columns("id", ("text", "context", "text_signals"), _pg_search_tokenizer())
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_text_search")
op.execute(f"""
CREATE INDEX idx_memory_units_text_search ON {table}
USING bm25 ({bm25_cols})
WITH (key_field='id')
""")
# vchord: tokenize() call in fact_storage.py is updated to include text_signals at insert time
# pg_textsearch: no change — index operates on the base `text` column only
def _pg_downgrade() -> None:
def downgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
@@ -106,22 +84,5 @@ def _pg_downgrade() -> None:
CREATE INDEX idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
elif text_search_ext == "pg_search":
# Restore the original (id, text, context) BM25 index without text_signals.
bm25_cols = pg_search_bm25_columns("id", ("text", "context"), _pg_search_tokenizer())
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_text_search")
op.execute(f"""
CREATE INDEX idx_memory_units_text_search ON {table}
USING bm25 ({bm25_cols})
WITH (key_field='id')
""")
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS text_signals")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -24,8 +24,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a2b3c4d5e6f8"
down_revision: str | Sequence[str] | None = "f7g8h9i0j1k2"
branch_labels: str | Sequence[str] | None = None
@@ -37,28 +35,20 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; an
# autocommit_block runs it outside Alembic's migration transaction.
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_source_memory_ids "
f"ON {schema}memory_units USING GIN (source_memory_ids) "
f"WHERE source_memory_ids IS NOT NULL"
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_source_memory_ids")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
# Commit the current Alembic transaction first.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_source_memory_ids "
f"ON {schema}memory_units USING GIN (source_memory_ids) "
f"WHERE source_memory_ids IS NOT NULL"
)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_source_memory_ids")
@@ -1,48 +0,0 @@
"""Add last_refreshed_source_query column to mental_models
Revision ID: a2v3w4x5y6z7
Revises: z1u2v3w4x5y6
Create Date: 2026-04-15
Tracks the source_query that was used during the most recent refresh.
Used by delta-mode refresh to detect when the query has changed: if it has,
delta mode falls back to a full regeneration because the surgical-edit
assumption (same topic, new facts) no longer holds.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a2v3w4x5y6z7"
down_revision: str | Sequence[str] | None = "z1u2v3w4x5y6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS last_refreshed_source_query TEXT
""")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS last_refreshed_source_query")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -13,8 +13,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a3b4c5d6e7f8"
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
branch_labels: str | Sequence[str] | None = None
@@ -27,7 +25,7 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
@@ -47,16 +45,8 @@ def _pg_upgrade() -> None:
)
def _pg_downgrade() -> None:
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,161 +0,0 @@
"""Fix per-bank vector indexes to match configured extension
Revision ID: a4b5c6d7e8f9
Revises: 2eee35aa3cfc
Create Date: 2026-04-01
Migration d5e6f7a8b9c0 hardcoded HNSW when creating per-bank partial vector
indexes, ignoring HINDSIGHT_API_VECTOR_EXTENSION. Banks that existed when that
migration ran got HNSW indexes even when pgvectorscale (DiskANN) or vchord
was configured.
This migration detects the mismatch and recreates the affected indexes with
the correct type. Skipped entirely when the configured extension is pgvector
(the default) or scann. ScaNN uses global vector indexes because empty or tiny
per-bank indexes cannot be built safely on AlloyDB.
"""
import os
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a4b5c6d7e8f9"
down_revision: str | Sequence[str] | None = "2eee35aa3cfc"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACT_TYPES: dict[str, str] = {
"world": "worl",
"experience": "expr",
"observation": "obsv",
}
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _validate_extension(name: str) -> str:
ext = name.lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _index_type_keyword(ext: str) -> str:
if ext == "pgvectorscale":
return "diskann"
if ext == "vchord":
return "vchordrq"
if ext == "scann":
return "scann"
return "hnsw"
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_cosine_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _pg_upgrade() -> None:
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
return
target = _index_type_keyword(ext)
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause(ext)
pg_schema = schema_name or "public"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
# Check if this index exists and what type it is
idx_info = bind.execute(
text("SELECT indexdef FROM pg_indexes WHERE schemaname = :schema AND indexname = :idx"),
{"schema": pg_schema, "idx": idx_name},
).fetchone()
if idx_info is None:
# Index doesn't exist — create it with the correct type
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
continue
indexdef = idx_info[0].lower()
if target in indexdef:
# Already the correct type
continue
# Wrong type — drop and recreate
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} {using_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
def _pg_downgrade() -> None:
# Downgrade recreates indexes as HNSW (the original hardcoded behavior)
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
return
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,253 +0,0 @@
"""Move mental-model and observation history into dedicated tables.
Both histories were accumulated in a single JSONB/CLOB ``history`` column
(``mental_models.history`` and ``memory_units.history``), appended to on every
update. That design has two problems:
1. **Unbounded growth on observations.** The observation write path appended a
snapshot on every update with no cap at all, so a frequently-reinforced
observation grew its ``history`` array until it crossed Postgres's hard 256MB
jsonb limit (SQLSTATE 54000), after which every further UPDATE failed and the
row was stuck.
2. **Wrong-axis cap on mental models.** The mental-model cap bounded the *number*
of entries (50), not their *size* — a single large reflect snapshot could
still blow the budget — and rewrote the whole array (plus TOAST) on every
refresh, defeating HOT updates.
This migration creates one row per history entry in two dedicated tables, with
an index that makes "most recent N for this item" cheap, then drops the old
columns. The cap is now enforced at write time as a bounded DELETE of the
oldest over-cap rows (see config ``*_HISTORY_MAX_ENTRIES``).
Revision ID: a7b8c9d0e1f2
Revises: d3e4f5a6b7c8
Create Date: 2026-06-05
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a7b8c9d0e1f2"
down_revision: str | Sequence[str] | None = "d3e4f5a6b7c8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Both tables share the same shape: surrogate id, FK to the parent, bank_id,
# the snapshot payload as a single JSONB ``content`` blob, and changed_at.
# The payload is per-row (one change per row) so it stays small — this is NOT
# the old single-column-grows-forever design; growth is bounded by row count
# plus the write-time cap. Folding the previous_* fields into one JSONB keeps
# the schema dialect-simple (no array columns) and flexible.
# --- mental_model_history -------------------------------------------------
# content: {"previous_content": ..., "previous_reflect_response": {...}}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_mm_history_model "
f"ON {schema}mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
# --- observation_history --------------------------------------------------
# content: {"previous_text", "previous_tags", "previous_occurred_start",
# "previous_occurred_end", "previous_mentioned_at", "new_source_memory_ids"}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
REFERENCES {schema}memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_observation_history_obs "
f"ON {schema}observation_history (observation_id, changed_at DESC, id DESC)"
)
# --- backfill mental models ----------------------------------------------
# Explode each row's history array into rows, preserving chronological order
# via WITH ORDINALITY so the IDENTITY id tie-breaks oldest->newest correctly.
# changed_at is promoted to its own column; the rest of the element becomes
# ``content`` (the ``- 'changed_at'`` strips the now-redundant key).
op.execute(
f"""
INSERT INTO {schema}mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}mental_models mm
CROSS JOIN LATERAL jsonb_array_elements(mm.history) WITH ORDINALITY a(e, ord)
WHERE mm.history IS NOT NULL
AND jsonb_typeof(mm.history) = 'array'
AND jsonb_array_length(mm.history) > 0
ORDER BY mm.id, mm.bank_id, ord
"""
)
# --- backfill observations -----------------------------------------------
op.execute(
f"""
INSERT INTO {schema}observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}memory_units mu
CROSS JOIN LATERAL jsonb_array_elements(mu.history) WITH ORDINALITY a(e, ord)
WHERE mu.fact_type = 'observation'
AND mu.history IS NOT NULL
AND jsonb_typeof(mu.history) = 'array'
AND jsonb_array_length(mu.history) > 0
ORDER BY mu.id, ord
"""
)
# --- drop the legacy columns ---------------------------------------------
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
# Re-add the columns (empty — historical content is not reconstructed back
# into the array form; the dedicated tables are dropped below).
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_observation_history_obs")
op.execute(f"DROP TABLE IF EXISTS {schema}observation_history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mm_history_model")
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_history")
# ---------------------------------------------------------------------------
# Oracle 23ai
# ---------------------------------------------------------------------------
def _oracle_upgrade() -> None:
# Same single-JSONB shape as PG: ``content`` holds the snapshot payload as a
# CLOB IS JSON. The legacy per-element JSON object (minus changed_at, promoted
# to its own column) is carried through verbatim on backfill — the array
# columns the previous design needed are gone.
op.execute(
"""
CREATE TABLE IF NOT EXISTS mental_model_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
mental_model_id VARCHAR2(256) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT mmh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_mental_model_history PRIMARY KEY (id),
CONSTRAINT fk_mmh_model FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_mm_history_model ON mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS observation_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
observation_id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT oh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_observation_history PRIMARY KEY (id),
CONSTRAINT fk_oh_obs FOREIGN KEY (observation_id)
REFERENCES memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_observation_history_obs ON observation_history (observation_id, changed_at DESC, id DESC)"
)
bind = op.get_bind()
# Backfill via JSON_TABLE. ``content`` is the whole element (FORMAT JSON PATH
# '$'); changed_at is also promoted to its own column. Backfilled content may
# therefore still carry a redundant changed_at key, which the read path
# ignores in favour of the column — harmless, and avoids JSON surgery here.
bind.exec_driver_sql(
"""
INSERT INTO mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM mental_models mm,
JSON_TABLE(mm.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mm.history IS NOT NULL
ORDER BY mm.id, mm.bank_id, jt.seq
"""
)
bind.exec_driver_sql(
"""
INSERT INTO observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM memory_units mu,
JSON_TABLE(mu.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mu.fact_type = 'observation' AND mu.history IS NOT NULL
ORDER BY mu.id, jt.seq
"""
)
op.execute("ALTER TABLE mental_models DROP COLUMN history")
op.execute("ALTER TABLE memory_units DROP COLUMN history")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE mental_models ADD history CLOB DEFAULT '[]' NOT NULL")
op.execute("ALTER TABLE memory_units ADD history CLOB DEFAULT '[]'")
op.execute("DROP TABLE observation_history CASCADE CONSTRAINTS")
op.execute("DROP TABLE mental_model_history CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -12,8 +12,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "aa2b3c4d5e6f"
down_revision: str | Sequence[str] | None = "z1u2v3w4x5y6"
branch_labels: str | Sequence[str] | None = None
@@ -26,21 +24,13 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date DROP NOT NULL")
def _pg_downgrade() -> None:
def downgrade() -> None:
schema = _get_schema_prefix()
# Backfill NULLs with now() before restoring the NOT NULL constraint
op.execute(f"UPDATE {schema}memory_units SET event_date = now() WHERE event_date IS NULL")
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date SET NOT NULL")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,156 +0,0 @@
"""Repair: install maintenance routines on the ``public`` / base-schema run.
The original maintenance-routines migration (``e5f6a7b8c9d0``) only created the
shared ``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` routines when the run had *no*
``target_schema`` at all. But the single-tenant runtime always migrates an
explicit schema — which defaults to ``public`` — so on every default
PostgreSQL deployment the migration was stamped as applied while the functions
were never created. Background maintenance then logs::
Retention sweep failed for llm_requests: function public.schemas_with_expired_rows(...) does not exist
Consolidation reconcile discovery failed: function public.banks_needing_consolidation() does not exist
See https://github.com/vectorize-io/hindsight/issues/2056.
Because ``e5f6a7b8c9d0`` is already stamped on affected ``0.8.0`` databases,
editing it would not re-run it there. This forward migration re-installs the
functions idempotently (``CREATE OR REPLACE``) on the run that targets the
shared ``public`` schema (base run with no ``target_schema``, or an explicit
``target_schema=public``), self-healing already-upgraded deployments and
covering fresh upgrades from earlier versions.
Per-tenant runs against a non-``public`` schema still skip it: re-issuing
``CREATE OR REPLACE FUNCTION public....`` from each concurrent tenant migration
aborts with ``tuple concurrently updated`` on the ``pg_proc`` catalog row, and
the base/public run has already created the functions for every tenant to use.
Runs that target ``public`` are serialized by the per-schema migration advisory
lock, so only one wins the create.
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
so the Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
Revision ID: b2d4f6a8c1e3
Revises: e5f6a7b8c9d0
Create Date: 2026-06-08
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b2d4f6a8c1e3"
down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routines.
The routines physically live in ``public`` (hard-coded ``public.`` qualifier
in the SQL below), so they must be installed exactly once — on the base run
(no ``target_schema``) or on the run that explicitly targets ``public``. A
run against any other tenant schema skips it to avoid concurrent
``CREATE OR REPLACE`` on the same ``pg_proc`` row.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
# Auto-consolidation is filtered here only at the bank level (cheap prune);
# the full hierarchical resolution (global -> tenant -> bank, plus
# enable_observations) is done by the caller for the small returned set.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
END LOOP;
END;
$fn$;
"""
)
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
# the timestamp column to compare. Returns nothing when p_days <= 0
# (retention disabled).
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: ``e5f6a7b8c9d0`` owns the lifecycle of these functions and drops
# them on its own downgrade. This migration only ever (re)creates them, so
# there is nothing to undo without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,42 +0,0 @@
"""add content_hash to chunks table for delta retain
Revision ID: b3c4d5e6f7a8
Revises: a3b4c5d6e7f8
Create Date: 2026-03-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b3c4d5e6f7a8"
down_revision: str | Sequence[str] | None = "a3b4c5d6e7f8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Add content_hash column to chunks table for delta comparison
op.execute(f"ALTER TABLE {schema}chunks ADD COLUMN IF NOT EXISTS content_hash TEXT")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}chunks DROP COLUMN IF EXISTS content_hash")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -22,8 +22,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b3c4d5e6f7g8"
down_revision: str | Sequence[str] | None = "c1a2b3d4e5f6"
branch_labels: str | Sequence[str] | None = None
@@ -35,42 +33,36 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; an
# autocommit_block runs each statement outside Alembic's migration transaction.
with op.get_context().autocommit_block():
# Partial index on occurred_start (covers "occurred_start BETWEEN $4 AND $5")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_start "
f"ON {schema}memory_units(bank_id, fact_type, occurred_start) "
f"WHERE occurred_start IS NOT NULL"
)
# Partial index on occurred_end (covers "occurred_end BETWEEN $4 AND $5")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_end "
f"ON {schema}memory_units(bank_id, fact_type, occurred_end) "
f"WHERE occurred_end IS NOT NULL"
)
# Partial index on mentioned_at (covers "mentioned_at BETWEEN $4 AND $5")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_mentioned_at "
f"ON {schema}memory_units(bank_id, fact_type, mentioned_at) "
f"WHERE mentioned_at IS NOT NULL"
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_mentioned_at")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_end")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_start")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
schema = _get_schema_prefix()
# Partial index on occurred_start (covers "occurred_start BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_start "
f"ON {schema}memory_units(bank_id, fact_type, occurred_start) "
f"WHERE occurred_start IS NOT NULL"
)
# Partial index on occurred_end (covers "occurred_end BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_end "
f"ON {schema}memory_units(bank_id, fact_type, occurred_end) "
f"WHERE occurred_end IS NOT NULL"
)
# Partial index on mentioned_at (covers "mentioned_at BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_mentioned_at "
f"ON {schema}memory_units(bank_id, fact_type, mentioned_at) "
f"WHERE mentioned_at IS NOT NULL"
)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_mentioned_at")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_end")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_start")
@@ -1,54 +0,0 @@
"""Add structured_content JSONB column to mental_models
Revision ID: b3w4x5y6z7a8
Revises: a2v3w4x5y6z7
Create Date: 2026-04-16
Stores the structured representation of a mental model document (sections,
blocks). The plain ``content`` column remains the rendered markdown shown to
users. ``structured_content`` is the source of truth for delta-mode refreshes:
each refresh applies a list of typed operations to the structured doc, then
re-renders to markdown — so unchanged sections come through byte-identical
without an LLM round-trip.
Nullable: existing markdown-only mental models continue to work in full mode;
the column is populated lazily the first time a model is refreshed in delta
mode.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b3w4x5y6z7a8"
down_revision: str | Sequence[str] | None = "a2v3w4x5y6z7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS structured_content JSONB
""")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS structured_content")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -14,8 +14,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b4c5d6e7f8a9"
down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7"
branch_labels: str | Sequence[str] | None = None
@@ -27,18 +25,10 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS observation_scopes JSONB")
def _pg_downgrade() -> None:
pass # intentionally no-op — safe to leave the column in place
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
pass # intentionally no-op — safe to leave the column in place
@@ -1,61 +0,0 @@
"""Add bank_stats_cache table for distributed get_bank_stats caching
Revision ID: b57a7c9e0d13
Revises: c3f7a1b9d2e4
Create Date: 2026-07-01
get_bank_stats aggregates over memory_links / unit_entities — a multi-second scan
on banks with millions of rows. The result was cached per-process (in-memory), so
every API worker recomputed it once per TTL and the first caller after expiry
stalled. This table backs a shared, cross-process TTL cache: one worker's compute
is written here and served to all the others.
PostgreSQL only. Oracle keeps the in-process cache (the runtime picks the backing
store by dialect), so the Oracle upgrade slot is intentionally absent.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b57a7c9e0d13"
down_revision: str | Sequence[str] | None = "c3f7a1b9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# One row per bank: payload is the full get_bank_stats result, computed_at
# drives logical TTL expiry. Rows are overwritten in place (ON CONFLICT), so
# the table never grows beyond the number of banks and needs no purge job.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}bank_stats_cache (
bank_id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP TABLE IF EXISTS {schema}bank_stats_cache")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,106 +0,0 @@
"""Add graph_maintenance_queue table
Queue of memory_units whose outgoing temporal/semantic links lost a
neighbour to a delete. Drained by the async graph_maintenance worker,
which tops the unit's links back up using the same probes retain runs.
The queue only targets the link-recompute pass. The worker also runs
bank-wide sweeps (orphan-entity prune, stale-cooccurrence prune) on each
invocation; those don't need per-target queueing.
Revision ID: b5a4c3e2f1d8
Revises: e9b2c7d1f3a4
Create Date: 2026-05-27
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b5a4c3e2f1d8"
down_revision: str | Sequence[str] | None = "e9b2c7d1f3a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Composite PK gives us natural ON CONFLICT DO NOTHING dedup when the same
# unit is enqueued from overlapping deletes. No FK to memory_units: if the
# unit is deleted between enqueue and drain, the worker observes it's gone
# and skips — a cascade would erase the work order, but that work has
# already been satisfied (no surviving row to maintain).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}graph_maintenance_queue (
bank_id TEXT NOT NULL,
unit_id UUID NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (bank_id, unit_id)
)
"""
)
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_graph_maintenance_queue_bank_enqueued
ON {schema}graph_maintenance_queue (bank_id, enqueued_at)
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_graph_maintenance_queue_bank_enqueued")
op.execute(f"DROP TABLE IF EXISTS {schema}graph_maintenance_queue")
def _oracle_execute_ignoring_955(sql: str) -> None:
"""Run a CREATE statement and swallow ORA-00955 (object already exists).
Mirrors the helper in the Oracle baseline migration so reruns stay safe
on a database where the table was created by an earlier partial run.
"""
block = (
"BEGIN "
"EXECUTE IMMEDIATE :stmt; "
"EXCEPTION WHEN OTHERS THEN "
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
"END;"
)
op.get_bind().exec_driver_sql(block, {"stmt": sql.strip()})
def _oracle_upgrade() -> None:
_oracle_execute_ignoring_955(
"""
CREATE TABLE graph_maintenance_queue (
bank_id VARCHAR2(256) NOT NULL,
unit_id RAW(16) NOT NULL,
enqueued_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_graph_maintenance_queue PRIMARY KEY (bank_id, unit_id)
)
"""
)
_oracle_execute_ignoring_955(
"CREATE INDEX idx_graph_maintenance_queue_bank_enqueued ON graph_maintenance_queue (bank_id, enqueued_at)"
)
def _oracle_downgrade() -> None:
op.execute("DROP INDEX idx_graph_maintenance_queue_bank_enqueued")
op.execute("DROP TABLE graph_maintenance_queue")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -1,91 +0,0 @@
"""Backfill entity_cooccurrences.last_cooccurred from memory_units event time
Revision ID: b5d4e3f2a1c9
Revises: o1a2b3c4d5e6
Create Date: 2026-04-24
The writer path in `entity_resolver.link_units_to_entities_batch` historically
stamped `entity_cooccurrences.last_cooccurred` with `datetime.now(UTC)` at
flush time, ignoring the source memory unit's event date. For normal online
retains that's fine (now ≈ event time), but for any corpus that was
backfilled in a single session — migrating from another memory system, for
example — every co-occurrence collapsed to the import moment, which hid the
underlying knowledge timeline from the dashboard's entity graph recency heat
and from any downstream consumer of the column.
The writer is fixed in the same change set to propagate the unit's event_date;
this migration repairs historical rows by reading the true event time off
`unit_entities × memory_units` (falling back to `created_at` when
`mentioned_at` / `occurred_start` are NULL, so rows never regress).
Oracle slot is intentionally absent: the Oracle baseline (`o1a2b3c4d5e6`)
landed days before this fix, so any Oracle deployment runs the corrected
writer against an effectively empty `entity_cooccurrences` — there is no
historical residue on Oracle to repair. PG-only matches the asymmetry of
the data, not negligence.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b5d4e3f2a1c9"
down_revision: str | Sequence[str] | None = "o1a2b3c4d5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Recompute last_cooccurred from the true event time per entity pair.
# COALESCE picks the first non-null of mentioned_at / occurred_start /
# created_at so banks without event-time metadata still see a sane value
# (equivalent to the pre-fix behaviour) instead of NULL.
#
# The self-join on `unit_entities` is O(k²) per memory_unit in the number
# of distinct entities mentioned (k). For typical units k is small (single
# digits), but a bank with units containing hundreds of entities and tens
# of millions of co-occurrence rows may want to run this off-hours — the
# whole UPDATE is one statement, so it locks every targeted ec row for
# the duration. The migration is one-time; subsequent online writes
# already carry event time via the writer fix.
op.execute(
f"""
UPDATE {schema}entity_cooccurrences ec
SET last_cooccurred = sub.event_time
FROM (
SELECT
LEAST(ue1.entity_id, ue2.entity_id) AS e1,
GREATEST(ue1.entity_id, ue2.entity_id) AS e2,
MAX(COALESCE(mu.mentioned_at, mu.occurred_start, mu.created_at)) AS event_time
FROM {schema}memory_units mu
JOIN {schema}unit_entities ue1 ON ue1.unit_id = mu.id
JOIN {schema}unit_entities ue2 ON ue2.unit_id = mu.id AND ue1.entity_id <> ue2.entity_id
GROUP BY 1, 2
) sub
WHERE ec.entity_id_1 = sub.e1 AND ec.entity_id_2 = sub.e2
"""
)
def _pg_downgrade() -> None:
# No-op: the previous column value was `now()` at the time of write and
# isn't recoverable. Rolling back the code is sufficient — new writes will
# revert to the old behaviour for subsequent retains.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent — see header
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -12,8 +12,6 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from hindsight_api.alembic._dialect import run_for_dialect
# revision identifiers, used by Alembic.
revision: str = "b7c4d8e9f1a2"
down_revision: str | Sequence[str] | None = "5a366d414dce"
@@ -21,7 +19,7 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
def upgrade() -> None:
"""Add chunks table and link memory_units to chunks."""
# Create chunks table with single text PK (bank_id_document_id_chunk_index)
@@ -58,7 +56,7 @@ def _pg_upgrade() -> None:
op.create_index("idx_memory_units_chunk_id", "memory_units", ["chunk_id"])
def _pg_downgrade() -> None:
def downgrade() -> None:
"""Remove chunks table and chunk_id from memory_units."""
# Drop index and foreign key from memory_units
@@ -70,11 +68,3 @@ def _pg_downgrade() -> None:
op.drop_index("idx_chunks_bank_id", table_name="chunks")
op.drop_index("idx_chunks_document_id", table_name="chunks")
op.drop_table("chunks")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,152 +0,0 @@
"""Re-create vchord vector indexes with vector_cosine_ops
Revision ID: b8c9d0e1f2a3
Revises: 86f7a033d372
Create Date: 2026-05-20
vchordrq operator classes are bound 1:1 to operators in PostgreSQL:
vector_l2_ops only matches ``<->``, while every Hindsight ANN query uses
``<=>`` (cosine distance). The previous vchord mapping used vector_l2_ops,
so vchord deployments could never use the index — every ANN query fell
back to a sequential scan with per-row cosine computation.
This migration finds any vchordrq index built with vector_l2_ops in the
target schema and re-creates it with vector_cosine_ops, using
``CREATE INDEX CONCURRENTLY`` so it can run online. It is a no-op when:
* the configured vector extension is not vchord, or
* no matching indexes exist (already on cosine ops).
Only PostgreSQL is affected; the Oracle 23ai dialect uses its own native
vector index and does not depend on this mapping.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api._vector_index import configured_vector_extension
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b8c9d0e1f2a3"
down_revision: str | Sequence[str] | None = "86f7a033d372"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _rebuild_vchordrq_indexes(old_ops: str, new_ops: str) -> None:
"""Rebuild vchordrq indexes using ``old_ops`` so they use ``new_ops``.
Each index is rebuilt with CREATE INDEX CONCURRENTLY under a fresh name,
then the old index is dropped and the new one renamed to take its place.
Must be called inside an ``autocommit_block()`` because CONCURRENTLY
cannot run inside a transaction.
"""
bind = op.get_bind()
# `or None` collapses both unset and explicit empty-string Alembic options
# into NULL so the COALESCE below falls back to current_schema() in either
# case. Without it, an empty-string option would filter on `schemaname = ''`
# and skip every real schema.
target_schema = context.config.get_main_option("target_schema") or None
prefix = _pg_schema_prefix()
rows = bind.execute(
text(
"SELECT indexname, indexdef FROM pg_indexes "
"WHERE schemaname = COALESCE(:target_schema, current_schema()) "
"AND indexdef ILIKE '%vchordrq%' "
"AND indexdef ILIKE :ops_like"
),
{"target_schema": target_schema, "ops_like": f"%{old_ops}%"},
).fetchall()
for idx_name, indexdef in rows:
# pg_get_indexdef() emits the canonical form `CREATE INDEX <name> ON …`,
# so <name> is the first textual occurrence — both substitutions below
# rely on that.
new_def = indexdef.replace(old_ops, new_ops, 1)
temp_name = f"{idx_name}__opclass_swap"
new_def = new_def.replace(idx_name, temp_name, 1)
new_def = re.sub(
r"^CREATE\s+INDEX\b",
"CREATE INDEX CONCURRENTLY IF NOT EXISTS",
new_def,
count=1,
)
# CREATE INDEX CONCURRENTLY can leave the partial index as INVALID if a
# previous run errored (disk pressure, lock conflict, signal). Without
# this drop the CONCURRENTLY IF NOT EXISTS below would skip creation,
# then we'd drop the original and rename the broken index into its
# place — silently restoring the seq-scan bug this migration fixes.
op.execute(f'DROP INDEX IF EXISTS {prefix}"{temp_name}"')
op.execute(new_def)
# Even on a clean run CONCURRENTLY can finish with indisvalid = false
# (e.g. constraint violation during the second build scan). Refuse to
# promote in that case so we never alias an INVALID index over a working
# one.
is_valid = bind.execute(
text(
"SELECT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"name": temp_name, "target_schema": target_schema},
).scalar()
if not is_valid:
raise RuntimeError(
f"vchordrq index rebuild produced an INVALID index ({temp_name}); "
"drop it manually and re-run the migration."
)
# DROP + RENAME atomically. A crash between the two would leave
# `temp_name` as a valid orphan and the canonical name missing —
# next run's `pg_indexes` filter (looking for vector_l2_ops) wouldn't
# find anything to recover from, so the index would stay gone. PG
# runs the DO block in its own server-side transaction, so either
# both succeed or both roll back.
op.execute(
f"""
DO $$
BEGIN
DROP INDEX IF EXISTS {prefix}"{idx_name}";
ALTER INDEX {prefix}"{temp_name}" RENAME TO "{idx_name}";
END $$;
"""
)
def _pg_upgrade() -> None:
if configured_vector_extension() != "vchord":
return
with op.get_context().autocommit_block():
_rebuild_vchordrq_indexes("vector_l2_ops", "vector_cosine_ops")
def _pg_downgrade() -> None:
if configured_vector_extension() != "vchord":
return
with op.get_context().autocommit_block():
_rebuild_vchordrq_indexes("vector_cosine_ops", "vector_l2_ops")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -11,11 +11,8 @@ block; see migrations.py for how this is handled safely.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c1a2b3d4e5f6"
down_revision: str | Sequence[str] | None = "b4c5d6e7f8a9"
branch_labels: str | Sequence[str] | None = None
@@ -27,44 +24,23 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
# pg_trgm ships with most PostgreSQL installations as a contrib module.
def upgrade() -> None:
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
# On managed services (e.g. Azure Flexible Server), the extension may not be
# available or may require manual enablement. We gracefully skip the index
# creation if the extension cannot be loaded — the entity resolver will
# auto-detect and fall back to the "full" lookup strategy at runtime. See #626.
conn = op.get_bind()
try:
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
except Exception:
# Extension not available (managed Postgres, insufficient privileges, etc.)
# Roll back the failed statement and skip index creation.
conn.execute(sa.text("ROLLBACK"))
conn.execute(sa.text("BEGIN"))
return
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
schema = _get_schema_prefix()
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
# (% operator, similarity()) instead of full-table scans across all bank entities.
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}entities_canonical_name_trgm_idx")
# Note: not dropping pg_trgm extension as other indexes may depend on it
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}entities_canonical_name_trgm_idx")
# Note: not dropping pg_trgm extension as other indexes may depend on it
@@ -1,45 +0,0 @@
"""Merge graph_maintenance_queue and vchord_cosine_opclass heads.
Revision ID: c1d2e3f4a5b6
Revises: b5a4c3e2f1d8, b8c9d0e1f2a3
Create Date: 2026-05-29
PRs #1668 (vchord cosine opclass) and #1772 (async link recompute) both
branched off the same parent and were merged onto main without rebasing,
leaving two parallel Alembic heads. This is a structural merge revision
with no schema changes — its only job is to unify the DAG so
``alembic upgrade head`` is unambiguous again.
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c1d2e3f4a5b6"
down_revision: str | Sequence[str] | None = ("b5a4c3e2f1d8", "b8c9d0e1f2a3")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
pass
def _pg_downgrade() -> None:
pass
def _oracle_upgrade() -> None:
pass
def _oracle_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -1,71 +0,0 @@
"""Add audit_log table for feature usage tracking.
Merge migration that combines the two existing heads (a3b4c5d6e7f8 + c8e5f2a3b4d1).
Stores raw request/response as JSONB for expandability without future migrations.
The metadata JSONB column allows adding arbitrary fields in the future.
Revision ID: c2d3e4f5g6h7
Revises: a3b4c5d6e7f8, c8e5f2a3b4d1
Create Date: 2026-03-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c2d3e4f5g6h7"
down_revision: str | Sequence[str] | None = ("a3b4c5d6e7f8", "c8e5f2a3b4d1")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action TEXT NOT NULL,
transport TEXT NOT NULL,
bank_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
request JSONB,
response JSONB,
metadata JSONB DEFAULT '{{}}'::jsonb
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_audit_log_action_started ON {schema}audit_log (action, started_at DESC)"
)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_bank_started ON {schema}audit_log (bank_id, started_at DESC)")
op.execute(f"CREATE INDEX IF NOT EXISTS idx_audit_log_started ON {schema}audit_log (started_at DESC)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_bank_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_audit_log_action_started")
op.execute(f"DROP TABLE IF EXISTS {schema}audit_log")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -9,8 +9,6 @@ from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6g7h8"
down_revision: str | Sequence[str] | None = ("a2b3c4d5e6f7", "a2b3c4d5e6f8")
branch_labels: str | Sequence[str] | None = None
@@ -22,19 +20,11 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
def _pg_downgrade() -> None:
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)

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