Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55dbc86b3 | ||
|
|
5f4a87fa41 | ||
|
|
80f2d7cbc6 | ||
|
|
8d8a10d70a | ||
|
|
4081445bfe | ||
|
|
6d0bb2b834 | ||
|
|
b40918958a | ||
|
|
2f7fe35bdd | ||
|
|
f4390bdc2e | ||
|
|
e0f0da5d2d | ||
|
|
a9e6d9f731 | ||
|
|
8ce06e3e7c | ||
|
|
365fa3ce50 | ||
|
|
2eb1019da9 | ||
|
|
2f2db2a6e2 | ||
|
|
caa53ee370 | ||
|
|
5cdc714a38 | ||
|
|
78aa7c537e | ||
|
|
3f31cbf505 | ||
|
|
b7abf8565a | ||
|
|
682cbf38ee | ||
|
|
5e8952c54a | ||
|
|
8364b9c5d5 | ||
|
|
5a486883e8 | ||
|
|
d2c32cb8e4 | ||
|
|
ce691549ba | ||
|
|
72b61214f6 | ||
|
|
103994c25f | ||
|
|
36b5627d2c | ||
|
|
d284de28c7 | ||
|
|
93609f74ab | ||
|
|
9a5f83adb4 | ||
|
|
b4320254b2 | ||
|
|
97f7a365e8 | ||
|
|
20e17f28ad | ||
|
|
80b1badf74 | ||
|
|
ea662d062e | ||
|
|
94cf89b570 | ||
|
|
439424559e | ||
|
|
4c4b3568db | ||
|
|
a706905653 | ||
|
|
fe12be47a0 | ||
|
|
a56cd044e5 | ||
|
|
438ce98b40 | ||
|
|
446c75f3e2 | ||
|
|
276a4ba7e8 | ||
|
|
31f1c53c8f |
@@ -0,0 +1,111 @@
|
||||
name: Release Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'integrations/**'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for PyPI trusted publishing
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract integration info
|
||||
id: info
|
||||
run: |
|
||||
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
|
||||
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
|
||||
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Integration: $INTEGRATION, Version: $VERSION"
|
||||
|
||||
- name: Detect integration type
|
||||
id: type
|
||||
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
|
||||
fi
|
||||
|
||||
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
|
||||
|
||||
- name: Install uv
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build Python package
|
||||
if: steps.type.outputs.type == 'python'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Publish Python package to PyPI
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
|
||||
skip-existing: true
|
||||
|
||||
# ── 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 --sparse hindsight-integrations"
|
||||
|
||||
# ── 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'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm ci
|
||||
|
||||
- name: Build TypeScript package
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm run build
|
||||
|
||||
- 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 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 }}
|
||||
@@ -46,30 +46,10 @@ jobs:
|
||||
working-directory: ./hindsight-all-slim
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-litellm
|
||||
working-directory: ./hindsight-integrations/litellm
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-embed
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-crewai
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-pydantic-ai
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-hermes
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-agno
|
||||
working-directory: ./hindsight-integrations/agno
|
||||
run: uv build --out-dir dist
|
||||
|
||||
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
|
||||
- name: Publish hindsight-client to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -101,42 +81,12 @@ jobs:
|
||||
packages-dir: ./hindsight-all-slim/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-litellm to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/litellm/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-embed to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-embed/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-crewai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/crewai/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-pydantic-ai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/pydantic-ai/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-hermes to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/hermes/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-agno to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/agno/dist
|
||||
skip-existing: true
|
||||
|
||||
# Upload artifacts for GitHub release
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -148,12 +98,7 @@ jobs:
|
||||
hindsight-api/dist/*
|
||||
hindsight-all/dist/*
|
||||
hindsight-all-slim/dist/*
|
||||
hindsight-integrations/litellm/dist/*
|
||||
hindsight-embed/dist/*
|
||||
hindsight-integrations/crewai/dist/*
|
||||
hindsight-integrations/pydantic-ai/dist/*
|
||||
hindsight-integrations/hermes/dist/*
|
||||
hindsight-integrations/agno/dist/*
|
||||
retention-days: 1
|
||||
|
||||
release-typescript-client:
|
||||
@@ -205,153 +150,6 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-openclaw-integration:
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
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-integrations/openclaw
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: hindsight-integrations/openclaw/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-ai-sdk-integration:
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
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-integrations/ai-sdk
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: hindsight-integrations/ai-sdk/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-chat-integration:
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
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-integrations/chat
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: chat-integration
|
||||
path: hindsight-integrations/chat/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -609,7 +407,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, 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
|
||||
|
||||
@@ -632,24 +430,6 @@ jobs:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download OpenClaw Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: ./artifacts/openclaw-integration
|
||||
|
||||
- name: Download AI SDK Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: ./artifacts/ai-sdk-integration
|
||||
|
||||
- name: Download Chat Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: chat-integration
|
||||
path: ./artifacts/chat-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -689,19 +469,9 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/hermes/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/agno/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# OpenClaw Integration
|
||||
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
|
||||
# AI SDK Integration
|
||||
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
|
||||
# Chat Integration
|
||||
cp artifacts/chat-integration/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
@@ -75,6 +75,24 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
test-claude-code-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install pytest
|
||||
run: pip install pytest
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/claude-code
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
build-ai-sdk-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1775,6 +1793,9 @@ jobs:
|
||||
- name: Run generate-clients
|
||||
run: ./scripts/generate-clients.sh
|
||||
|
||||
- name: Run generate-docs-skill
|
||||
run: ./scripts/generate-docs-skill.sh
|
||||
|
||||
- name: Run lint
|
||||
run: ./scripts/hooks/lint.sh
|
||||
|
||||
@@ -1789,6 +1810,7 @@ jobs:
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo " ./scripts/generate-openapi.sh"
|
||||
echo " ./scripts/generate-clients.sh"
|
||||
echo " ./scripts/generate-docs-skill.sh"
|
||||
echo " ./scripts/hooks/lint.sh"
|
||||
echo ""
|
||||
git diff --stat
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
|
||||
[](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://gitcgr.com/vectorize-io/hindsight)
|
||||

|
||||

|
||||
<br/>
|
||||
|
||||
+15
-2
@@ -11,6 +11,7 @@ block; see migrations.py for how this is handled safely.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "c1a2b3d4e5f6"
|
||||
@@ -25,9 +26,21 @@ def _get_schema_prefix() -> str:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
|
||||
# pg_trgm ships with most PostgreSQL installations as a contrib module.
|
||||
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
# 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
|
||||
|
||||
schema = _get_schema_prefix()
|
||||
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
|
||||
|
||||
@@ -169,6 +169,15 @@ class RecallRequest(BaseModel):
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def validate_query_not_empty(cls, v: str) -> str:
|
||||
from ..engine.search.retrieval import tokenize_query
|
||||
|
||||
if not tokenize_query(v):
|
||||
raise ValueError("query must contain at least one word character after normalization")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "RecallRequest":
|
||||
if self.tags is not None and self.tag_groups is not None:
|
||||
@@ -669,6 +678,25 @@ class ReflectRequest(BaseModel):
|
||||
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "ReflectRequest":
|
||||
@@ -1435,6 +1463,25 @@ class MentalModelTrigger(BaseModel):
|
||||
default=False,
|
||||
description="If true, refresh this mental model after observations consolidation (real-time mode)",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
|
||||
class MentalModelResponse(BaseModel):
|
||||
@@ -2505,6 +2552,9 @@ def _register_routes(app: FastAPI):
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
fact_types=request.fact_types,
|
||||
exclude_mental_models=request.exclude_mental_models,
|
||||
exclude_mental_model_ids=request.exclude_mental_model_ids,
|
||||
)
|
||||
|
||||
# Build based_on (memories + mental_models + directives) if facts are requested
|
||||
@@ -2580,6 +2630,12 @@ def _register_routes(app: FastAPI):
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except TimeoutError as e:
|
||||
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=str(e) or "Reflect operation timed out. Consider reducing the budget or simplifying the query.",
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default
|
||||
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
|
||||
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
|
||||
|
||||
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
|
||||
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
|
||||
|
||||
|
||||
def get_current_bank_id() -> str | None:
|
||||
"""Get the current bank_id from context."""
|
||||
@@ -104,6 +107,11 @@ def get_current_api_key_id() -> str | None:
|
||||
return _current_api_key_id.get()
|
||||
|
||||
|
||||
def get_current_mcp_authenticated() -> bool:
|
||||
"""Get whether the request was pre-authenticated by MCP transport auth."""
|
||||
return _current_mcp_authenticated.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"""
|
||||
Create and configure the Hindsight MCP server.
|
||||
@@ -164,6 +172,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
|
||||
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
|
||||
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
|
||||
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
|
||||
include_bank_id_param=multi_bank,
|
||||
tools=base_tools,
|
||||
)
|
||||
@@ -312,6 +321,7 @@ class MCPMiddleware:
|
||||
tenant_context = None
|
||||
auth_tenant_id: str | None = None
|
||||
auth_api_key_id: str | None = None
|
||||
mcp_pre_authenticated = False
|
||||
if MCP_AUTH_TOKEN:
|
||||
# Legacy authentication mode - validate against static token
|
||||
if not auth_token:
|
||||
@@ -320,8 +330,9 @@ class MCPMiddleware:
|
||||
if auth_token != MCP_AUTH_TOKEN:
|
||||
await self._send_error(send, 401, "Invalid authentication token")
|
||||
return
|
||||
# Legacy mode doesn't use tenant schemas
|
||||
# Legacy mode: mark as pre-authenticated so tenant extension won't re-validate
|
||||
tenant_context = None
|
||||
mcp_pre_authenticated = True
|
||||
else:
|
||||
# Use TenantExtension.authenticate_mcp() for auth
|
||||
try:
|
||||
@@ -368,13 +379,15 @@ class MCPMiddleware:
|
||||
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
|
||||
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
|
||||
|
||||
# Set bank_id, api_key, tenant_id, and api_key_id context
|
||||
# Set bank_id, api_key, tenant_id, api_key_id, and mcp_authenticated context
|
||||
bank_id_token = _current_bank_id.set(bank_id)
|
||||
# Store the auth token for tenant extension to validate
|
||||
api_key_token = _current_api_key.set(auth_token) if auth_token else None
|
||||
# Store tenant_id and api_key_id from authentication for usage metering
|
||||
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
|
||||
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
|
||||
# Store MCP pre-authentication flag to skip tenant re-validation
|
||||
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
|
||||
try:
|
||||
new_scope = scope.copy()
|
||||
new_scope["path"] = new_path
|
||||
@@ -419,6 +432,7 @@ class MCPMiddleware:
|
||||
_current_tenant_id.reset(tenant_id_token)
|
||||
if api_key_id_token is not None:
|
||||
_current_api_key_id.reset(api_key_id_token)
|
||||
_current_mcp_authenticated.reset(mcp_auth_token)
|
||||
if schema_token is not None:
|
||||
_current_schema.reset(schema_token)
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLO
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
|
||||
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
|
||||
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
|
||||
|
||||
# Disposition settings
|
||||
@@ -499,6 +500,7 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
|
||||
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
|
||||
|
||||
# Disposition defaults (None = not set, fall back to bank DB value or 3)
|
||||
DEFAULT_DISPOSITION_SKEPTICISM = None
|
||||
@@ -801,6 +803,7 @@ class HindsightConfig:
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
reflect_max_context_tokens: int
|
||||
reflect_wall_timeout: int
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled: bool
|
||||
@@ -1274,6 +1277,7 @@ class HindsightConfig:
|
||||
reflect_max_context_tokens=int(
|
||||
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
|
||||
),
|
||||
reflect_wall_timeout=int(os.getenv(ENV_REFLECT_WALL_TIMEOUT, str(DEFAULT_REFLECT_WALL_TIMEOUT))),
|
||||
reflect_mission=os.getenv(ENV_REFLECT_MISSION) or None,
|
||||
# Disposition settings (None = fall back to DB value)
|
||||
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
|
||||
|
||||
@@ -75,6 +75,7 @@ class EntityResolver:
|
||||
"""
|
||||
self.pool = pool
|
||||
self.entity_lookup = entity_lookup
|
||||
self._pg_trgm_checked = False
|
||||
# Keyed by asyncio task id so concurrent retain batches never mix their
|
||||
# pending updates. flush_pending_stats() pops only the calling task's items.
|
||||
self._pending_stats: dict[int, list[_EntityStat]] = {}
|
||||
@@ -202,6 +203,20 @@ class EntityResolver:
|
||||
taxonomy_lookup: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
if self.entity_lookup == "trigram":
|
||||
# Auto-detect pg_trgm availability on first call and fall back to
|
||||
# "full" strategy if the extension is not installed. See #626.
|
||||
if not self._pg_trgm_checked:
|
||||
self._pg_trgm_checked = True
|
||||
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
|
||||
if not has_trgm:
|
||||
logger.warning(
|
||||
"pg_trgm extension is not available — falling back to 'full' "
|
||||
"entity lookup strategy. Install pg_trgm for faster entity "
|
||||
"resolution on large banks. See: "
|
||||
"https://github.com/vectorize-io/hindsight/issues/626"
|
||||
)
|
||||
self.entity_lookup = "full"
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
|
||||
@@ -477,19 +492,42 @@ class EntityResolver:
|
||||
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
|
||||
|
||||
# Fallback SELECT for names that conflicted (another worker won the race).
|
||||
missing = [n for n, _ in sorted_groups if n not in id_by_name]
|
||||
if missing:
|
||||
#
|
||||
# IMPORTANT: we must let PostgreSQL do the lowercasing on BOTH sides of the
|
||||
# comparison. Python's str.lower() and PostgreSQL's LOWER() differ for some
|
||||
# Unicode characters — most notably Turkish İ (U+0130):
|
||||
# Python: 'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
|
||||
# PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)
|
||||
# Passing a Python-lowercased name to "LOWER(canonical_name) = ANY($2::text[])"
|
||||
# would fail to match the stored entity, leaving entity_id as None and causing
|
||||
# a NOT NULL constraint violation on unit_entities.entity_id.
|
||||
#
|
||||
# Fix: pass the original (mixed-case) input names and use
|
||||
# "LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n)" so
|
||||
# PostgreSQL lowercases both sides identically. The query also returns the
|
||||
# original input_name so we can index id_by_name by Python's lower() of that
|
||||
# name, which is what the assignment loop below uses as its lookup key.
|
||||
missing_original = [g.name for name_lower, g in sorted_groups if name_lower not in id_by_name]
|
||||
if missing_original:
|
||||
existing_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, LOWER(canonical_name) AS name_lower
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
|
||||
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
|
||||
FROM {fq_table("entities")} e
|
||||
JOIN (
|
||||
SELECT LOWER(n) AS input_name_lower, n AS input_name
|
||||
FROM unnest($2::text[]) AS n
|
||||
) AS inputs ON LOWER(e.canonical_name) = inputs.input_name_lower
|
||||
WHERE e.bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
missing,
|
||||
missing_original,
|
||||
)
|
||||
for row in existing_rows:
|
||||
id_by_name[row["name_lower"]] = row["id"]
|
||||
# Also index by Python's lower() of the original input name so the
|
||||
# assignment loop (which uses Python-lowercased keys) finds it even
|
||||
# when Python and PostgreSQL produce different lowercase strings.
|
||||
id_by_name[row["input_name"].lower()] = row["id"]
|
||||
|
||||
# Assign entity IDs back and queue one stat per original mention so that
|
||||
# flush_pending_stats() increments mention_count by the true mention count,
|
||||
|
||||
@@ -67,6 +67,13 @@ def fq_table(table_name: str) -> str:
|
||||
return f"{get_current_schema()}.{table_name}"
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> str:
|
||||
"""JSON serializer for types commonly carried through async task payloads."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
# Tables that must be schema-qualified (for runtime validation)
|
||||
_PROTECTED_TABLES = frozenset(
|
||||
[
|
||||
@@ -492,24 +499,28 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"""The configured tenant extension, if any."""
|
||||
return self._tenant_extension
|
||||
|
||||
async def _validate_operation(self, validation_coro) -> None:
|
||||
async def _validate_operation(self, validation_coro) -> "ValidationResult | None":
|
||||
"""
|
||||
Run validation if an operation validator is configured.
|
||||
|
||||
Args:
|
||||
validation_coro: Coroutine that returns a ValidationResult
|
||||
|
||||
Returns:
|
||||
The ValidationResult (may contain enrichment fields), or None if no validator.
|
||||
|
||||
Raises:
|
||||
OperationValidationError: If validation fails
|
||||
"""
|
||||
if self._operation_validator is None:
|
||||
return
|
||||
return None
|
||||
|
||||
from hindsight_api.extensions import OperationValidationError
|
||||
from hindsight_api.extensions import OperationValidationError, ValidationResult
|
||||
|
||||
result = await validation_coro
|
||||
if not result.allowed:
|
||||
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
|
||||
return result
|
||||
|
||||
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
|
||||
"""
|
||||
@@ -538,6 +549,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if request_context.internal:
|
||||
return _current_schema.get()
|
||||
|
||||
# For MCP requests already authenticated via MCP_AUTH_TOKEN, skip tenant re-validation.
|
||||
# The MCP transport layer already verified the token; re-validating against the tenant
|
||||
# extension would fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
if request_context.mcp_authenticated:
|
||||
return _current_schema.get()
|
||||
|
||||
# Authenticate through tenant extension (always set, may be default no-auth extension)
|
||||
tenant_context = await self._tenant_extension.authenticate(request_context)
|
||||
|
||||
@@ -868,14 +885,23 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect to generate new content, excluding the mental model being refreshed
|
||||
# Always add self to excluded IDs to prevent circular reference
|
||||
reflect_result = await self.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query=source_query,
|
||||
request_context=internal_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
)
|
||||
|
||||
generated_content = reflect_result.text or "No content generated"
|
||||
@@ -2030,7 +2056,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
fact_type_override=fact_type_override,
|
||||
confidence_score=confidence_score,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
|
||||
if document_id:
|
||||
@@ -2397,8 +2425,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_entity_tokens=max_entity_tokens,
|
||||
include_chunks=include_chunks,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
if result:
|
||||
if result.tags is not None:
|
||||
tags = result.tags
|
||||
if result.tags_match is not None:
|
||||
tags_match = result.tags_match
|
||||
if result.tag_groups is not None:
|
||||
tag_groups = result.tag_groups
|
||||
|
||||
# Map budget enum to thinking_budget number (default to MID if None)
|
||||
budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000}
|
||||
@@ -5113,6 +5151,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
exclude_mental_model_ids: list[str] | None = None,
|
||||
fact_types: list[str] | None = None,
|
||||
exclude_mental_models: bool = False,
|
||||
_skip_span: bool = False,
|
||||
) -> ReflectResult:
|
||||
"""
|
||||
@@ -5188,6 +5228,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
effective_budget = budget or Budget.LOW
|
||||
max_iterations = max(1, int(base_max_iterations * budget_multipliers.get(effective_budget, 1.0)))
|
||||
max_context_tokens = config.reflect_max_context_tokens
|
||||
wall_timeout = config.reflect_wall_timeout
|
||||
|
||||
# Run agentic loop - acquire connections only when needed for DB operations
|
||||
# (not held during LLM calls which can be slow)
|
||||
@@ -5233,6 +5274,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
pending_consolidation=pending_consolidation,
|
||||
)
|
||||
|
||||
# Determine which tools to enable based on fact_types and exclude_mental_models
|
||||
include_observations = fact_types is None or "observation" in fact_types
|
||||
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
|
||||
include_recall = bool(recall_fact_types)
|
||||
|
||||
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
|
||||
return await tool_recall(
|
||||
self,
|
||||
@@ -5244,6 +5290,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
fact_types=recall_fact_types if fact_types is not None else None,
|
||||
)
|
||||
|
||||
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
|
||||
@@ -5266,15 +5313,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if directives:
|
||||
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
|
||||
|
||||
# Check if the bank has any mental models
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
# Check if the bank has any mental models (skip check if all mental models are excluded)
|
||||
has_mental_models = False
|
||||
if not exclude_mental_models:
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
|
||||
# Run the agent with parent span for reflect operation (skip if called from another operation)
|
||||
if not _skip_span:
|
||||
@@ -5284,29 +5333,52 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
span_context = None
|
||||
|
||||
try:
|
||||
agent_result = await run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
try:
|
||||
agent_result = await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
),
|
||||
timeout=wall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
total_time = time.time() - reflect_start
|
||||
logger.error(
|
||||
"[REFLECT %s] Wall-clock timeout after %.1fs (limit: %ss) for query: %.50s...",
|
||||
reflect_id,
|
||||
total_time,
|
||||
wall_timeout,
|
||||
query,
|
||||
)
|
||||
raise TimeoutError(
|
||||
f"Reflect operation timed out after {wall_timeout} seconds. "
|
||||
f"Consider reducing the budget or simplifying the query."
|
||||
)
|
||||
|
||||
total_time = time.time() - reflect_start
|
||||
logger.info(
|
||||
f"[REFLECT {reflect_id}] Complete: {len(agent_result.text)} chars, "
|
||||
f"{agent_result.iterations} iterations, {agent_result.tools_called} tool calls | {total_time:.3f}s"
|
||||
"[REFLECT %s] Complete: %d chars, %d iterations, %d tool calls | %.3fs",
|
||||
reflect_id,
|
||||
len(agent_result.text),
|
||||
agent_result.iterations,
|
||||
agent_result.tools_called,
|
||||
total_time,
|
||||
)
|
||||
|
||||
# Convert agent tool trace to ToolCallTrace objects
|
||||
@@ -6430,6 +6502,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect with the source query, excluding the mental model being refreshed
|
||||
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
|
||||
reflect_result = await self.reflect_async(
|
||||
@@ -6438,7 +6516,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
_skip_span=True,
|
||||
)
|
||||
|
||||
@@ -7407,21 +7487,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
operation_id = uuid.uuid4()
|
||||
|
||||
# Insert operation record into database
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}),
|
||||
"pending",
|
||||
)
|
||||
|
||||
# Build and submit task payload
|
||||
# Build full payload before INSERT so task_payload is included atomically.
|
||||
# Previously the INSERT omitted task_payload and a separate submit_task call
|
||||
# did an UPDATE — a crash between the two left a null-payload row that the
|
||||
# worker's claim query (task_payload IS NOT NULL) could never pick up.
|
||||
full_payload = {
|
||||
"type": task_type,
|
||||
"operation_id": str(operation_id),
|
||||
@@ -7429,6 +7498,24 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
**task_payload,
|
||||
}
|
||||
|
||||
# Insert operation record with task_payload in a single atomic statement
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status, task_payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}, default=_json_default),
|
||||
"pending",
|
||||
json.dumps(full_payload, default=_json_default),
|
||||
)
|
||||
|
||||
# For SyncTaskBackend: executes the task immediately.
|
||||
# For BrokerTaskBackend: does an idempotent UPDATE (payload already set above),
|
||||
# kept for symmetry and to support any future notification mechanisms.
|
||||
await self._task_backend.submit_task(full_payload)
|
||||
|
||||
logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
|
||||
@@ -7462,7 +7549,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
contents=[dict(c) for c in contents],
|
||||
request_context=request_context,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Validate no duplicate document_ids in the batch
|
||||
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
|
||||
|
||||
@@ -24,6 +24,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
@@ -39,6 +40,25 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip markdown code fences from LLM response if present.
|
||||
|
||||
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
|
||||
wrap JSON responses in ```json ... ``` fences even when json_object
|
||||
response format is requested. This strips the fences while preserving
|
||||
the JSON content inside. Returns the original content unchanged if
|
||||
no fences are detected.
|
||||
"""
|
||||
if "```" not in content:
|
||||
return content
|
||||
try:
|
||||
if "```json" in content:
|
||||
return content.split("```json")[1].split("```")[0].strip()
|
||||
return content.split("```")[1].split("```")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
return content
|
||||
|
||||
|
||||
class OpenAICompatibleLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider for OpenAI-compatible APIs.
|
||||
@@ -108,10 +128,18 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# Get timeout config
|
||||
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
|
||||
|
||||
# Create OpenAI client
|
||||
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
|
||||
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
parsed = urlparse(self.base_url)
|
||||
if parsed.query:
|
||||
clean_url = urlunparse(parsed._replace(query=""))
|
||||
client_kwargs["base_url"] = clean_url
|
||||
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||
client_kwargs["default_query"] = default_query
|
||||
self.base_url = clean_url
|
||||
else:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
|
||||
@@ -313,20 +341,14 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
if len(content) < original_len:
|
||||
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
|
||||
|
||||
# For local models, they may wrap JSON in markdown code blocks
|
||||
if self.provider in ("lmstudio", "ollama"):
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content
|
||||
json_data = json.loads(content)
|
||||
else:
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
# Strip markdown code fences if present — any provider may
|
||||
# produce these (confirmed with MiniMax, some Ollama models,
|
||||
# Claude via proxies). No-op when content is already bare JSON.
|
||||
clean_content = _strip_code_fences(content)
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content in case stripping was wrong
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
@@ -721,26 +743,33 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
result = response.json()
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse JSON response
|
||||
# Strip markdown code fences if present (safety net —
|
||||
# Ollama with schema enforcement usually returns bare JSON,
|
||||
# but some models may still wrap in fences)
|
||||
clean_content = _strip_code_fences(content)
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to raw content
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
# Extract token usage from Ollama response
|
||||
duration = time.time() - start_time
|
||||
|
||||
@@ -316,6 +316,8 @@ async def run_reflect_agent(
|
||||
response_schema: dict | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
has_mental_models: bool = False,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
budget: str | None = None,
|
||||
max_context_tokens: int = 100_000,
|
||||
) -> ReflectAgentResult:
|
||||
@@ -355,7 +357,14 @@ async def run_reflect_agent(
|
||||
directive_rules = _extract_directive_rules(directives) if directives else None
|
||||
|
||||
# Get tools for this agent (with directive compliance field if directives exist)
|
||||
tools = get_reflect_tools(directive_rules=directive_rules)
|
||||
tools = get_reflect_tools(
|
||||
directive_rules=directive_rules,
|
||||
include_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
)
|
||||
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
|
||||
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
|
||||
|
||||
# Build initial messages (directives are injected into system prompt at START and END)
|
||||
system_prompt = build_system_prompt_for_tools(
|
||||
@@ -538,19 +547,18 @@ async def run_reflect_agent(
|
||||
llm_start = time.time()
|
||||
|
||||
# Determine tool_choice for this iteration.
|
||||
# Force the full hierarchical retrieval path before allowing auto:
|
||||
# With mental models:
|
||||
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
|
||||
# Without mental models:
|
||||
# 0 → search_observations, 1 → recall, 2+ → auto
|
||||
if iteration == 0 and has_mental_models:
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
elif iteration == 0:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 and has_mental_models:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 or (iteration == 2 and has_mental_models):
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
|
||||
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
|
||||
# Build the forced sequence from the tools that are actually enabled.
|
||||
forced_sequence = []
|
||||
if has_mental_models:
|
||||
forced_sequence.append("search_mental_models")
|
||||
if include_observations:
|
||||
forced_sequence.append("search_observations")
|
||||
if include_recall:
|
||||
forced_sequence.append("recall")
|
||||
|
||||
if iteration < len(forced_sequence):
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
|
||||
else:
|
||||
iter_tool_choice = "auto"
|
||||
|
||||
@@ -769,7 +777,17 @@ async def run_reflect_agent(
|
||||
# Execute other tools in parallel (exclude done tool in all its format variants)
|
||||
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
|
||||
if other_tools:
|
||||
# Add assistant message with tool calls
|
||||
# Partition into enabled vs hallucinated (not in enabled_tools set)
|
||||
allowed_tools = []
|
||||
hallucinated_tools = []
|
||||
for tc in other_tools:
|
||||
norm = _normalize_tool_name(tc.name)
|
||||
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
|
||||
hallucinated_tools.append(tc)
|
||||
else:
|
||||
allowed_tools.append(tc)
|
||||
|
||||
# Build assistant message with all tool calls (LLM requires them for history)
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -777,6 +795,23 @@ async def run_reflect_agent(
|
||||
}
|
||||
)
|
||||
|
||||
# Immediately reject hallucinated tool calls without adding to trace
|
||||
for tc in hallucinated_tools:
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
other_tools = allowed_tools
|
||||
|
||||
# Execute tools in parallel
|
||||
tool_tasks = [
|
||||
_execute_tool_with_timing(
|
||||
@@ -785,6 +820,7 @@ async def run_reflect_agent(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
for tc in other_tools
|
||||
]
|
||||
@@ -974,6 +1010,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Execute a tool call and return result with timing."""
|
||||
from hindsight_api.tracing import get_tracer
|
||||
@@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
# Set success attributes
|
||||
@@ -1046,11 +1084,16 @@ async def _execute_tool(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single tool by name."""
|
||||
# Normalize tool name for various LLM output formats
|
||||
tool_name = _normalize_tool_name(tool_name)
|
||||
|
||||
# Guard against LLMs hallucinating calls to tools that were not provided
|
||||
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
|
||||
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
|
||||
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
|
||||
@@ -200,6 +200,7 @@ async def tool_recall(
|
||||
tag_groups: "list | None" = None,
|
||||
connection_budget: int = 1,
|
||||
max_chunk_tokens: int = 1000,
|
||||
fact_types: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search memories using TEMPR retrieval.
|
||||
@@ -217,15 +218,18 @@ async def tool_recall(
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
|
||||
connection_budget: Max DB connections for this recall (default 1 for internal ops)
|
||||
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
|
||||
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
|
||||
|
||||
Returns:
|
||||
Dict with list of matching memories including raw chunk text
|
||||
"""
|
||||
# Only world/experience are valid for raw recall (observation is handled by search_observations)
|
||||
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
|
||||
include_chunks = True
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=["experience", "world"],
|
||||
fact_type=recall_fact_type,
|
||||
max_tokens=max_tokens,
|
||||
enable_trace=False,
|
||||
request_context=request_context,
|
||||
|
||||
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
def get_reflect_tools(
|
||||
directive_rules: list[str] | None = None,
|
||||
include_mental_models: bool = True,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get the list of tools for the reflect agent.
|
||||
|
||||
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
Args:
|
||||
directive_rules: Optional list of directive rule strings. If provided,
|
||||
the done() tool will require directive compliance confirmation.
|
||||
include_mental_models: Whether to include the search_mental_models tool.
|
||||
include_observations: Whether to include the search_observations tool.
|
||||
include_recall: Whether to include the recall tool.
|
||||
|
||||
Returns:
|
||||
List of tool definitions in OpenAI format
|
||||
"""
|
||||
tools = [
|
||||
TOOL_SEARCH_MENTAL_MODELS,
|
||||
TOOL_SEARCH_OBSERVATIONS,
|
||||
TOOL_RECALL,
|
||||
TOOL_EXPAND,
|
||||
]
|
||||
tools = []
|
||||
|
||||
if include_mental_models:
|
||||
tools.append(TOOL_SEARCH_MENTAL_MODELS)
|
||||
if include_observations:
|
||||
tools.append(TOOL_SEARCH_OBSERVATIONS)
|
||||
if include_recall:
|
||||
tools.append(TOOL_RECALL)
|
||||
|
||||
tools.append(TOOL_EXPAND)
|
||||
|
||||
# Use directive-aware done tool if directives are present
|
||||
if directive_rules:
|
||||
|
||||
@@ -1083,30 +1083,16 @@ async def _extract_facts_from_chunk(
|
||||
logger.warning(f"Skipping fact {i}: missing 'what' field")
|
||||
continue
|
||||
|
||||
# Critical field: fact_type
|
||||
# LLM uses "assistant" but we convert to "experience" for storage
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience" for storage
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type (after conversion)
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
# Default to 'world' if we can't determine
|
||||
fact_type = "world"
|
||||
logger.warning(
|
||||
f"Fact {i}: defaulting to fact_type='world' "
|
||||
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
|
||||
)
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Get fact_kind for temporal handling (but don't store it)
|
||||
fact_kind = llm_fact.get("fact_kind", "conversation")
|
||||
@@ -1754,23 +1740,17 @@ async def extract_facts_from_contents_batch_api(
|
||||
who = get_value("who")
|
||||
why = get_value("why")
|
||||
|
||||
# Critical field: fact_type
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience"
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — only "assistant" maps to "experience", everything else is "world"
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
fact_type = "world"
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Build combined fact text
|
||||
combined_parts = [what]
|
||||
@@ -1933,7 +1913,7 @@ async def extract_facts_from_contents_batch_api(
|
||||
for fact_from_llm in chunk_facts:
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
|
||||
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
|
||||
@@ -2110,7 +2090,7 @@ async def extract_facts_from_contents(
|
||||
# mentioned_at is always the event_date (when the conversation/document occurred)
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
# occurred_start/end: from LLM only, leave None if not provided
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start)
|
||||
|
||||
@@ -10,6 +10,7 @@ Implements:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
@@ -26,6 +27,15 @@ from .types import MPFPTimings, RetrievalResult
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tokenize_query(query_text: str) -> list[str]:
|
||||
"""Normalize query text and split into BM25 tokens.
|
||||
|
||||
Strips punctuation, lowercases, and splits on whitespace.
|
||||
Returns an empty list when the query contains no word characters.
|
||||
"""
|
||||
return re.sub(r"[^\w\s]", " ", query_text.lower()).split()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelRetrievalResult:
|
||||
"""Result from parallel retrieval across all methods."""
|
||||
@@ -129,12 +139,9 @@ async def retrieve_semantic_bm25_combined(
|
||||
Returns:
|
||||
Dict mapping fact_type -> (semantic_results, bm25_results)
|
||||
"""
|
||||
import re
|
||||
|
||||
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
|
||||
|
||||
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
|
||||
tokens = [token for token in sanitized_text.split() if token]
|
||||
tokens = tokenize_query(query_text)
|
||||
|
||||
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
|
||||
hnsw_fetch = max(limit * 5, 100)
|
||||
@@ -148,11 +155,15 @@ async def retrieve_semantic_bm25_combined(
|
||||
# --- Parameter layout ---
|
||||
# $1 = query_emb_str (semantic arms)
|
||||
# $2 = bank_id
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text (only when tokens present)
|
||||
# $N = tags (N=4 when no tokens, N=5 when tokens present)
|
||||
# $M+ = tag_groups params (one per leaf, starting after tags param)
|
||||
tags_param_idx = 5 if tokens else 4
|
||||
# When tokens present:
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text
|
||||
# $5 = tags (if present)
|
||||
# $6+ = tag_groups params (one per leaf)
|
||||
# When no tokens ($3 is skipped — not included in params to avoid type inference gap):
|
||||
# $3 = tags (if present)
|
||||
# $4+ = tag_groups params (one per leaf)
|
||||
tags_param_idx = 5 if tokens else 3
|
||||
tags_clause = build_tags_where_clause_simple(tags, tags_param_idx, match=tags_match)
|
||||
|
||||
# tag_groups params start immediately after the tags param slot
|
||||
@@ -222,9 +233,10 @@ async def retrieve_semantic_bm25_combined(
|
||||
|
||||
query = "\nUNION ALL\n".join(arms)
|
||||
|
||||
params: list = [query_emb_str, bank_id, limit]
|
||||
params: list = [query_emb_str, bank_id]
|
||||
if tokens:
|
||||
params.append(bm25_text_param)
|
||||
params.append(limit) # $3: BM25 LIMIT (only referenced when tokens are present)
|
||||
params.append(bm25_text_param) # $4
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
|
||||
from hindsight_api.engine.response_models import ReflectResult
|
||||
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
@@ -25,17 +26,51 @@ class OperationValidationError(Exception):
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of an operation validation."""
|
||||
"""Result of an operation validation.
|
||||
|
||||
Validators return this to accept or reject an operation. When accepting,
|
||||
validators can optionally return modified data that the engine will use
|
||||
instead of the original request parameters. This enables context enrichment
|
||||
(e.g., injecting tags or tag_groups).
|
||||
"""
|
||||
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
status_code: int = 403 # Default to Forbidden
|
||||
# Optional enrichment fields — returned by validator, used by engine if present.
|
||||
# None means "no modification" (engine uses original values).
|
||||
contents: list[dict] | None = None # Enriched retain contents (e.g., injected tags/strategy)
|
||||
tags: list[str] | None = None # Enriched recall tags
|
||||
tags_match: "TagsMatch | None" = None # Enriched recall tags match mode
|
||||
tag_groups: "list[TagGroup] | None" = None # Enriched recall tag_groups
|
||||
|
||||
@classmethod
|
||||
def accept(cls) -> "ValidationResult":
|
||||
"""Create an accepted validation result."""
|
||||
"""Create an accepted validation result (no enrichment)."""
|
||||
return cls(allowed=True)
|
||||
|
||||
@classmethod
|
||||
def accept_with(
|
||||
cls,
|
||||
*,
|
||||
contents: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: "TagsMatch | None" = None,
|
||||
tag_groups: "list[TagGroup] | None" = None,
|
||||
) -> "ValidationResult":
|
||||
"""Create an accepted validation result with enriched data.
|
||||
|
||||
The engine will use the returned values instead of the original request
|
||||
parameters. Only non-None fields are applied; None means "keep original".
|
||||
"""
|
||||
return cls(
|
||||
allowed=True,
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
|
||||
"""Create a rejected validation result with a reason and HTTP status code."""
|
||||
@@ -52,10 +87,12 @@ class RetainContext:
|
||||
"""Context for a retain operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the retain operation.
|
||||
To enrich contents (e.g., inject tags or strategy), return them
|
||||
via ValidationResult.accept_with(contents=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
contents: list[dict] # List of {content, context, event_date, document_id}
|
||||
contents: list[dict] # List of {content, context, event_date, document_id, tags, strategy}
|
||||
request_context: "RequestContext"
|
||||
document_id: str | None = None
|
||||
fact_type_override: str | None = None
|
||||
@@ -67,6 +104,8 @@ class RecallContext:
|
||||
"""Context for a recall operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the recall operation.
|
||||
To enrich tag filters (e.g., inject tag_groups), return them
|
||||
via ValidationResult.accept_with(tag_groups=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
@@ -81,6 +120,9 @@ class RecallContext:
|
||||
max_entity_tokens: int = 500
|
||||
include_chunks: bool = False
|
||||
max_chunk_tokens: int = 8192
|
||||
tags: list[str] | None = None
|
||||
tags_match: "TagsMatch" = "any"
|
||||
tag_groups: "list[TagGroup] | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -13,6 +13,7 @@ Stop with Ctrl+C.
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import dataclasses
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -152,178 +153,7 @@ def main():
|
||||
# Configure Python logging based on log level
|
||||
# Update config with CLI override if provided
|
||||
if args.log_level != config.log_level:
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
database_schema=config.database_schema,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
llm_base_url=config.llm_base_url,
|
||||
llm_max_concurrent=config.llm_max_concurrent,
|
||||
llm_max_retries=config.llm_max_retries,
|
||||
llm_initial_backoff=config.llm_initial_backoff,
|
||||
llm_max_backoff=config.llm_max_backoff,
|
||||
llm_timeout=config.llm_timeout,
|
||||
llm_groq_service_tier=config.llm_groq_service_tier,
|
||||
llm_openai_service_tier=config.llm_openai_service_tier,
|
||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||
llm_vertexai_region=config.llm_vertexai_region,
|
||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
||||
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
|
||||
retain_llm_provider=config.retain_llm_provider,
|
||||
retain_llm_api_key=config.retain_llm_api_key,
|
||||
retain_llm_model=config.retain_llm_model,
|
||||
retain_llm_base_url=config.retain_llm_base_url,
|
||||
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
|
||||
retain_llm_max_retries=config.retain_llm_max_retries,
|
||||
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
|
||||
retain_llm_max_backoff=config.retain_llm_max_backoff,
|
||||
retain_llm_timeout=config.retain_llm_timeout,
|
||||
reflect_llm_provider=config.reflect_llm_provider,
|
||||
reflect_llm_api_key=config.reflect_llm_api_key,
|
||||
reflect_llm_model=config.reflect_llm_model,
|
||||
reflect_llm_base_url=config.reflect_llm_base_url,
|
||||
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
|
||||
reflect_llm_max_retries=config.reflect_llm_max_retries,
|
||||
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
|
||||
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
|
||||
reflect_llm_timeout=config.reflect_llm_timeout,
|
||||
consolidation_llm_provider=config.consolidation_llm_provider,
|
||||
consolidation_llm_api_key=config.consolidation_llm_api_key,
|
||||
consolidation_llm_model=config.consolidation_llm_model,
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
|
||||
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
|
||||
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
|
||||
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
|
||||
consolidation_llm_timeout=config.consolidation_llm_timeout,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
|
||||
embeddings_cohere_model=config.embeddings_cohere_model,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
|
||||
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
|
||||
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
reranker_local_fp16=config.reranker_local_fp16,
|
||||
reranker_local_bucket_batching=config.reranker_local_bucket_batching,
|
||||
reranker_local_batch_size=config.reranker_local_batch_size,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
reranker_cohere_api_key=config.reranker_cohere_api_key,
|
||||
reranker_cohere_model=config.reranker_cohere_model,
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
reranker_litellm_max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
|
||||
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
|
||||
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
|
||||
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
|
||||
reranker_zeroentropy_api_key=config.reranker_zeroentropy_api_key,
|
||||
reranker_zeroentropy_model=config.reranker_zeroentropy_model,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
mcp_enabled_tools=config.mcp_enabled_tools,
|
||||
enable_bank_config_api=config.enable_bank_config_api,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
recall_connection_budget=config.recall_connection_budget,
|
||||
recall_max_query_tokens=config.recall_max_query_tokens,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_mission=config.retain_mission,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_default_strategy=config.retain_default_strategy,
|
||||
retain_strategies=config.retain_strategies,
|
||||
retain_batch_tokens=config.retain_batch_tokens,
|
||||
retain_entity_lookup=config.retain_entity_lookup,
|
||||
retain_batch_enabled=config.retain_batch_enabled,
|
||||
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
|
||||
file_storage_type=config.file_storage_type,
|
||||
file_storage_s3_bucket=config.file_storage_s3_bucket,
|
||||
file_storage_s3_region=config.file_storage_s3_region,
|
||||
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
|
||||
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
|
||||
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
|
||||
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
|
||||
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
|
||||
file_storage_azure_container=config.file_storage_azure_container,
|
||||
file_storage_azure_account_name=config.file_storage_azure_account_name,
|
||||
file_storage_azure_account_key=config.file_storage_azure_account_key,
|
||||
file_parser=config.file_parser,
|
||||
file_parser_allowlist=config.file_parser_allowlist,
|
||||
file_parser_iris_token=config.file_parser_iris_token,
|
||||
file_parser_iris_org_id=config.file_parser_iris_org_id,
|
||||
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
|
||||
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
|
||||
enable_file_upload_api=config.enable_file_upload_api,
|
||||
file_delete_after_retain=config.file_delete_after_retain,
|
||||
enable_observations=config.enable_observations,
|
||||
enable_observation_history=config.enable_observation_history,
|
||||
enable_mental_model_history=config.enable_mental_model_history,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
|
||||
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
observations_mission=config.observations_mission,
|
||||
entity_labels=config.entity_labels,
|
||||
entities_allow_free_form=config.entities_allow_free_form,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
db_pool_min_size=config.db_pool_min_size,
|
||||
db_pool_max_size=config.db_pool_max_size,
|
||||
db_command_timeout=config.db_command_timeout,
|
||||
db_acquire_timeout=config.db_acquire_timeout,
|
||||
worker_enabled=config.worker_enabled,
|
||||
worker_id=config.worker_id,
|
||||
worker_poll_interval_ms=config.worker_poll_interval_ms,
|
||||
worker_max_retries=config.worker_max_retries,
|
||||
worker_http_port=config.worker_http_port,
|
||||
worker_max_slots=config.worker_max_slots,
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
reflect_max_context_tokens=config.reflect_max_context_tokens,
|
||||
reflect_mission=config.reflect_mission,
|
||||
disposition_skepticism=config.disposition_skepticism,
|
||||
disposition_literalism=config.disposition_literalism,
|
||||
disposition_empathy=config.disposition_empathy,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
otel_traces_enabled=config.otel_traces_enabled,
|
||||
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
|
||||
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
|
||||
otel_service_name=config.otel_service_name,
|
||||
otel_deployment_environment=config.otel_deployment_environment,
|
||||
webhook_url=config.webhook_url,
|
||||
webhook_secret=config.webhook_secret,
|
||||
webhook_event_types=config.webhook_event_types,
|
||||
webhook_delivery_poll_interval_seconds=config.webhook_delivery_poll_interval_seconds,
|
||||
)
|
||||
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
config.log_config()
|
||||
|
||||
@@ -42,6 +42,9 @@ class MCPToolsConfig:
|
||||
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
||||
api_key_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve mcp_authenticated flag (set when MCP_AUTH_TOKEN validates)
|
||||
mcp_authenticated_resolver: Callable[[], bool] | None = None
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
@@ -64,7 +67,10 @@ def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
||||
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
||||
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
|
||||
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
|
||||
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
|
||||
mcp_authenticated = config.mcp_authenticated_resolver() if config.mcp_authenticated_resolver else False
|
||||
return RequestContext(
|
||||
api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id, mcp_authenticated=mcp_authenticated
|
||||
)
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
|
||||
@@ -21,6 +21,7 @@ class RequestContext:
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (skips extension auth)
|
||||
mcp_authenticated: bool = False # True when MCP transport auth already validated (skips tenant re-auth)
|
||||
user_initiated: bool = False # True for async operations that originated from a user request
|
||||
allowed_bank_ids: list[str] | None = None # None = unrestricted (all banks)
|
||||
|
||||
|
||||
@@ -302,19 +302,106 @@ class WorkerPoller:
|
||||
)
|
||||
|
||||
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
|
||||
"""Mark a task as failed with error message."""
|
||||
"""Mark a task as failed with error message, then propagate to parent if applicable."""
|
||||
table = fq_table("async_operations", schema)
|
||||
# Truncate error message if too long (max 5000 chars in schema)
|
||||
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
await self._maybe_update_parent_operation(operation_id, schema, conn)
|
||||
|
||||
async def _maybe_update_parent_operation(self, child_operation_id: str, schema: str | None, conn) -> None:
|
||||
"""If this operation is a child of a batch_retain, update the parent status when all siblings are done.
|
||||
|
||||
Must be called within an active transaction that has already updated the child's status.
|
||||
The memory engine has an equivalent method that runs inside task execution transactions.
|
||||
This poller-level version handles the case where a task fails via an unhandled exception
|
||||
that bypasses the memory engine's own failure path (e.g. a DB constraint violation that
|
||||
rolls back the engine's transaction before it can update the parent).
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT result_metadata, bank_id FROM {table} WHERE operation_id = $1",
|
||||
uuid.UUID(child_operation_id),
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
result_metadata = row["result_metadata"] or {}
|
||||
if isinstance(result_metadata, str):
|
||||
result_metadata = json.loads(result_metadata)
|
||||
parent_operation_id = result_metadata.get("parent_operation_id")
|
||||
if not parent_operation_id:
|
||||
return
|
||||
|
||||
bank_id = row["bank_id"]
|
||||
|
||||
# Lock parent to prevent concurrent sibling updates
|
||||
parent_row = await conn.fetchrow(
|
||||
f"SELECT operation_id FROM {table} WHERE operation_id = $1 AND bank_id = $2 FOR UPDATE",
|
||||
uuid.UUID(parent_operation_id),
|
||||
bank_id,
|
||||
)
|
||||
if not parent_row:
|
||||
return
|
||||
|
||||
# Check whether all siblings are done
|
||||
siblings = await conn.fetch(
|
||||
f"""
|
||||
SELECT status FROM {table}
|
||||
WHERE bank_id = $1
|
||||
AND result_metadata::jsonb @> $2::jsonb
|
||||
""",
|
||||
bank_id,
|
||||
json.dumps({"parent_operation_id": parent_operation_id}),
|
||||
)
|
||||
if not siblings or not all(s["status"] in ("completed", "failed") for s in siblings):
|
||||
return
|
||||
|
||||
any_failed = any(s["status"] == "failed" for s in siblings)
|
||||
if any_failed:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
"One or more sub-batches failed",
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'completed', updated_at = now(), completed_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
)
|
||||
logger.info(
|
||||
f"Poller updated parent operation {parent_operation_id} to "
|
||||
f"{'failed' if any_failed else 'completed'} (all siblings done)"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't re-raise — the child has already been marked failed,
|
||||
# which is the critical state change. A stuck parent will be caught on
|
||||
# the next run or via monitoring.
|
||||
logger.error(f"Failed to update parent operation for child {child_operation_id}: {e}")
|
||||
|
||||
async def _schedule_retry(self, operation_id: str, retry_at: "Any", error_message: str, schema: str | None):
|
||||
"""Reset task to pending with a future retry timestamp."""
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
|
||||
"uvloop>=0.22.1",
|
||||
# Transitive dependency security fixes
|
||||
"pyasn1>=0.6.2", # DoS vulnerability fix
|
||||
"pyasn1>=0.6.3", # DoS vulnerability fix
|
||||
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
|
||||
"langchain-core>=1.2.11", # Serialization injection + SSRF vulnerability fix
|
||||
"langsmith>=0.6.3", # SSRF via tracing header injection fix
|
||||
@@ -53,9 +53,12 @@ dependencies = [
|
||||
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
|
||||
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.6", # Account takeover vulnerability fix
|
||||
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
|
||||
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
|
||||
"orjson>=3.11.6", # Unbounded recursion DoS fix
|
||||
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
|
||||
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
|
||||
"claude-agent-sdk>=0.1.27; sys_platform == 'darwin'",
|
||||
"claude-agent-sdk>=0.1.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -48,7 +48,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Session-scoped fixture that ensures pg0 is running, migrations are applied,
|
||||
and returns the database URL.
|
||||
|
||||
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
|
||||
If HINDSIGHT_API_DATABASE_URL is a plain postgresql:// URL, uses it directly.
|
||||
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
|
||||
Otherwise, starts pg0 once for the entire test session.
|
||||
|
||||
Uses filelock to ensure only one pytest-xdist worker starts pg0.
|
||||
@@ -58,10 +59,23 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
|
||||
processes that share the same pg0 instance. pg0 will persist for the next test run.
|
||||
"""
|
||||
if db_url:
|
||||
# Use provided database URL directly
|
||||
from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url
|
||||
|
||||
# Determine pg0 instance name/port from db_url (if it's a pg0:// URL) or use defaults
|
||||
if db_url and not _parse_pg0_url(db_url)[0]:
|
||||
# Plain postgresql:// URL - use it directly but still run migrations
|
||||
from hindsight_api.migrations import run_migrations
|
||||
run_migrations(db_url)
|
||||
return db_url
|
||||
|
||||
if db_url:
|
||||
_, pg0_name, pg0_port = _parse_pg0_url(db_url)
|
||||
pg0_instance_name = pg0_name or DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = pg0_port or DEFAULT_PG0_PORT
|
||||
else:
|
||||
pg0_instance_name = DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = DEFAULT_PG0_PORT
|
||||
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
# Running without xdist (-n 0 or no -n flag)
|
||||
@@ -71,8 +85,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
# Use a lock file to ensure only one worker starts pg0
|
||||
lock_file = root_tmp_dir / "pg0_setup.lock"
|
||||
url_file = root_tmp_dir / "pg0_url.txt"
|
||||
lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock"
|
||||
url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
|
||||
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
if url_file.exists():
|
||||
@@ -80,7 +94,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
url = url_file.read_text().strip()
|
||||
else:
|
||||
# First worker - start pg0
|
||||
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
|
||||
pg0 = EmbeddedPostgres(name=pg0_instance_name, port=pg0_instance_port)
|
||||
|
||||
# Run ensure_running in a new event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Tests for EntityResolver edge cases.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
from hindsight_api.pg0 import resolve_database_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
|
||||
"""
|
||||
Existing entities with PostgreSQL/Python lowercase mismatches should resolve
|
||||
to the conflicted row instead of leaving a missing entity_id.
|
||||
"""
|
||||
resolved_url = await resolve_database_url(pg0_db_url)
|
||||
pool = await asyncpg.create_pool(resolved_url, min_size=1, max_size=2, command_timeout=30)
|
||||
bank_id = f"test-entity-resolver-{uuid.uuid4().hex[:8]}"
|
||||
event_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||
resolver = EntityResolver(pool=pool, entity_lookup="full")
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
existing_entity_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
|
||||
VALUES ($1, $2, $3, $3, 1)
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
"İstanbul",
|
||||
event_date,
|
||||
)
|
||||
|
||||
resolved_ids = await resolver.resolve_entities_batch(
|
||||
bank_id=bank_id,
|
||||
entities_data=[
|
||||
{
|
||||
"text": "istanbul",
|
||||
"nearby_entities": [],
|
||||
"event_date": event_date,
|
||||
}
|
||||
],
|
||||
context="unicode case mismatch",
|
||||
unit_event_date=event_date,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, canonical_name
|
||||
FROM entities
|
||||
WHERE bank_id = $1
|
||||
ORDER BY canonical_name
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
assert resolved_ids == [existing_entity_id]
|
||||
assert len(entity_rows) == 1
|
||||
assert entity_rows[0]["id"] == existing_entity_id
|
||||
assert entity_rows[0]["canonical_name"] == "İstanbul"
|
||||
finally:
|
||||
await pool.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
|
||||
await pool.close()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Unit tests for EntityResolver pg_trgm auto-detection (PR #626/#649).
|
||||
|
||||
These tests verify:
|
||||
1. When entity_lookup="trigram" and pg_trgm IS available, the trigram path is used.
|
||||
2. When entity_lookup="trigram" and pg_trgm is NOT available, the resolver falls back
|
||||
to entity_lookup="full" and uses the full-scan path.
|
||||
3. The pg_trgm check is only performed once (_pg_trgm_checked flag prevents re-checking).
|
||||
4. When entity_lookup="full" from the start, the trgm check is never performed.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
|
||||
|
||||
def _make_conn(pg_trgm_available: bool) -> MagicMock:
|
||||
"""Create a minimal mock asyncpg connection for the pg_trgm availability check."""
|
||||
conn = MagicMock()
|
||||
conn.fetchval = AsyncMock(return_value=pg_trgm_available)
|
||||
conn.fetch = AsyncMock(return_value=[])
|
||||
conn.executemany = AsyncMock()
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
return conn
|
||||
|
||||
|
||||
def _make_resolver(entity_lookup: str = "trigram") -> EntityResolver:
|
||||
"""Return an EntityResolver with a None pool (not needed for unit tests)."""
|
||||
return EntityResolver(pool=None, entity_lookup=entity_lookup) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestPgTrgmAutoDetection:
|
||||
"""Unit tests for pg_trgm detection logic inside _resolve_entities_batch_impl."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_full_when_pg_trgm_unavailable(self):
|
||||
"""When pg_trgm is absent the resolver switches to 'full' and calls the full-scan path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path must NOT be called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path must be called as the fallback
|
||||
mock_full.assert_called_once()
|
||||
# Strategy is permanently downgraded
|
||||
assert resolver.entity_lookup == "full"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_trigram_when_pg_trgm_available(self):
|
||||
"""When pg_trgm is present the trigram path is used."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
mock_trgm.assert_called_once()
|
||||
mock_full.assert_not_called()
|
||||
assert resolver.entity_lookup == "trigram"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_trgm_check_performed_only_once(self):
|
||||
"""The fetchval check is only issued on the first call; subsequent calls skip it."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])):
|
||||
# First call — check is issued
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — check must NOT be issued again
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval (the pg_trgm availability query) should be called exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_strategy_skips_pg_trgm_check(self):
|
||||
"""When entity_lookup='full' from the start, no pg_trgm check is ever issued."""
|
||||
resolver = _make_resolver(entity_lookup="full")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval should never be called when entity_lookup is already "full"
|
||||
conn.fetchval.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_is_sticky_across_calls(self):
|
||||
"""After falling back to 'full', subsequent calls also use the full path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
# First call triggers the fallback
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — _pg_trgm_checked is True so no re-check; entity_lookup=="full"
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path is never called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path is called both times
|
||||
assert mock_full.call_count == 2
|
||||
# pg_trgm check was issued exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
@@ -5,6 +5,7 @@ End-to-end tests for file retain (upload, convert, retain) functionality.
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -471,6 +472,77 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
|
||||
assert len(doc["original_text"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_file_retain_serializes_datetime_timestamp(memory_no_llm_verify, sample_txt_content):
|
||||
"""Async file retain should accept Python datetimes in task payloads."""
|
||||
from hindsight_api.engine.parsers.base import FileParser
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = f"test_file_timestamp_bank_{datetime.now(timezone.utc).timestamp()}"
|
||||
timestamp = datetime(2024, 1, 15, 10, 30, tzinfo=timezone.utc)
|
||||
|
||||
context = RequestContext(internal=True)
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
class TimestampParser(FileParser):
|
||||
async def convert(self, file_data: bytes, filename: str) -> str:
|
||||
return file_data.decode("utf-8")
|
||||
|
||||
def supports(self, filename: str, content_type: str | None = None) -> bool:
|
||||
return filename.endswith(".txt")
|
||||
|
||||
def name(self) -> str:
|
||||
return "timestamp_parser"
|
||||
|
||||
memory_no_llm_verify._parser_registry.register(TimestampParser())
|
||||
|
||||
mock_file = MockFile(sample_txt_content, "timestamped.txt", "text/plain")
|
||||
|
||||
result = await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=[
|
||||
{
|
||||
"file": mock_file,
|
||||
"document_id": "timestamped_doc",
|
||||
"context": "timestamp test",
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": timestamp,
|
||||
"parser": ["timestamp_parser"],
|
||||
}
|
||||
],
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
operation_id = result["operation_ids"][0]
|
||||
pool = await memory_no_llm_verify._get_pool()
|
||||
from hindsight_api.engine.memory_engine import get_current_schema
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT status, task_payload->>'timestamp' AS timestamp
|
||||
FROM {get_current_schema()}.async_operations
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
assert row is not None
|
||||
assert row["status"] == "completed"
|
||||
assert row["timestamp"] == "2024-01-15T10:30:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that when file conversion fails, the operation status is set to 'failed' not 'completed'."""
|
||||
|
||||
@@ -4,6 +4,9 @@ This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
|
||||
and that URLs with or without trailing slashes both work (no 307 redirect).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.client.session import ClientSession
|
||||
@@ -278,3 +281,93 @@ async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
|
||||
|
||||
assert "retain" in tools
|
||||
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
|
||||
"""Test that MCP tool calls work when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
|
||||
Regression test for https://github.com/vectorize-io/hindsight/issues/627
|
||||
When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are configured
|
||||
with different values, tool calls should succeed because MCP transport auth
|
||||
already validated the token — the tenant extension should not re-validate.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
# Configure ApiKeyTenantExtension with a different key than the MCP token
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
# Patch MCP_AUTH_TOKEN so the MCP middleware uses legacy auth
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
# Pass auth header via the httpx client (streamable_http_client doesn't accept headers)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {mcp_token}"},
|
||||
) as http_client:
|
||||
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# list_tools should work
|
||||
tools_result = await session.list_tools()
|
||||
tool_names = {t.name for t in tools_result.tools}
|
||||
assert "get_bank" in tool_names
|
||||
|
||||
# Tool execution should work (this was failing before the fix)
|
||||
result = await session.call_tool("list_banks", arguments={})
|
||||
assert result is not None
|
||||
assert len(result.content) > 0
|
||||
parsed = json.loads(result.content[0].text)
|
||||
assert "banks" in parsed
|
||||
assert "error" not in parsed, f"Tool call failed with: {parsed.get('error')}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
|
||||
"""Test that an invalid MCP token is rejected even if it matches the tenant key.
|
||||
|
||||
When MCP_AUTH_TOKEN is set, the MCP middleware should validate against that token,
|
||||
not the tenant API key.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
# Try connecting with the tenant key (wrong for MCP auth)
|
||||
response = await http_client.post(
|
||||
"http://test/mcp/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -5,8 +5,10 @@ These tests verify:
|
||||
1. Tool name normalization for various LLM output formats
|
||||
2. Recovery from unknown tool calls
|
||||
3. Recovery from tool execution errors
|
||||
4. Wall-clock timeout enforcement
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -416,6 +418,32 @@ class TestReflectAgentMocked:
|
||||
assert result is not None
|
||||
assert result.iterations == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wall_clock_timeout(self, mock_llm: MagicMock, mock_functions: dict[str, AsyncMock]) -> None:
|
||||
"""Test that asyncio.wait_for can enforce a wall-clock timeout on run_reflect_agent."""
|
||||
|
||||
async def slow_llm_call(*args: object, **kwargs: object) -> LLMToolCallResult:
|
||||
await asyncio.sleep(10) # Simulate a slow LLM call
|
||||
return LLMToolCallResult(
|
||||
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
mock_llm.call_with_tools.side_effect = slow_llm_call
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=mock_llm,
|
||||
bank_id="test-bank",
|
||||
query="test query",
|
||||
bank_profile={"name": "Test", "mission": "Testing"},
|
||||
max_iterations=5,
|
||||
**mock_functions,
|
||||
),
|
||||
timeout=0.1, # Very short timeout to trigger quickly
|
||||
)
|
||||
|
||||
|
||||
class TestContextOverflowHelpers:
|
||||
"""Unit tests for context-overflow detection helpers."""
|
||||
|
||||
@@ -485,3 +485,206 @@ class TestReflectUsesMentalModels:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestMentalModelReflectOptions:
|
||||
"""Tests for fact_types and exclude_mental_models options stored in the trigger field."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists fact_types and returns them via get_mental_model."""
|
||||
bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Observations only",
|
||||
source_query="Summarize observations",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["fact_types"] == ["observation"]
|
||||
assert fetched["trigger"]["refresh_after_consolidation"] is False
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_models flag."""
|
||||
bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="No mental models",
|
||||
source_query="Summarize raw facts",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_models": True},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_models"] is True
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_model_ids list."""
|
||||
bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
excluded_ids = ["mm-abc", "mm-xyz"]
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Exclude some models",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context):
|
||||
"""update_mental_model persists updated trigger reflect options."""
|
||||
bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Initially no filter",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
updated = await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
trigger={
|
||||
"refresh_after_consolidation": True,
|
||||
"fact_types": ["world", "experience"],
|
||||
"exclude_mental_models": False,
|
||||
"exclude_mental_model_ids": ["mm-skip"],
|
||||
},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert updated["trigger"]["refresh_after_consolidation"] is True
|
||||
assert updated["trigger"]["fact_types"] == ["world", "experience"]
|
||||
assert updated["trigger"]["exclude_mental_models"] is False
|
||||
assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"]
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectFactTypeFiltering:
|
||||
"""Tests for fact_types and exclude_mental_models filtering in reflect_async."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_mental_models_skips_search_mental_models_tool(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""When exclude_mental_models=True, search_mental_models is never called."""
|
||||
bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model so the bank has one
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Existing Model",
|
||||
source_query="Q",
|
||||
content="Some content about the team",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about the team",
|
||||
request_context=request_context,
|
||||
exclude_mental_models=True,
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_mental_models" not in tool_names, (
|
||||
f"search_mental_models should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types excludes observation, search_observations is never called."""
|
||||
bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["world", "experience"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_observations" not in tool_names, (
|
||||
f"search_observations should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types=['observation'], recall is never called."""
|
||||
bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["observation"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectRequestValidation:
|
||||
"""Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] to reflect must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={"query": "test", "fact_types": []},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] inside trigger must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models",
|
||||
json={
|
||||
"name": "Test",
|
||||
"source_query": "Q",
|
||||
"trigger": {"refresh_after_consolidation": False, "fact_types": []},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
|
||||
|
||||
|
||||
class TestStripCodeFences:
|
||||
"""Test markdown code fence stripping from LLM responses."""
|
||||
|
||||
def test_bare_json_unchanged(self):
|
||||
"""Bare JSON passes through unchanged."""
|
||||
content = '{"facts": [{"what": "test"}]}'
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_json_fence_stripped(self):
|
||||
"""```json ... ``` fences are stripped."""
|
||||
content = '```json\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_plain_fence_stripped(self):
|
||||
"""``` ... ``` fences without language tag are stripped."""
|
||||
content = '```\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_fence_with_trailing_whitespace(self):
|
||||
"""Fences with extra whitespace are handled."""
|
||||
content = '```json\n{"facts": []}\n```\n'
|
||||
result = _strip_code_fences(content)
|
||||
assert result == '{"facts": []}'
|
||||
|
||||
def test_fence_with_leading_whitespace(self):
|
||||
"""Content with leading whitespace before fence."""
|
||||
content = ' ```json\n{"facts": []}\n```'
|
||||
# The function checks for ``` in content, not startswith
|
||||
result = _strip_code_fences(content)
|
||||
assert '{"facts": []}' in result
|
||||
|
||||
def test_no_fences_no_change(self):
|
||||
"""Content without any backticks passes through."""
|
||||
content = "Just some text without fences"
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string passes through."""
|
||||
assert _strip_code_fences("") == ""
|
||||
|
||||
def test_multiline_json(self):
|
||||
"""Multi-line JSON inside fences is preserved."""
|
||||
content = '```json\n{\n "facts": [\n {"what": "line1"},\n {"what": "line2"}\n ]\n}\n```'
|
||||
result = _strip_code_fences(content)
|
||||
assert '"line1"' in result
|
||||
assert '"line2"' in result
|
||||
assert "```" not in result
|
||||
|
||||
def test_malformed_fence_returns_original(self):
|
||||
"""Malformed fences (missing closing) return something parseable."""
|
||||
content = '```json\n{"facts": []}'
|
||||
result = _strip_code_fences(content)
|
||||
# Should attempt to strip and return best effort
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_minimax_style_response(self):
|
||||
"""Real-world MiniMax response format."""
|
||||
content = (
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "facts": [\n'
|
||||
" {\n"
|
||||
' "what": "Sebastian switched the Hindsight extraction LLM",\n'
|
||||
' "when": "2026-03-21",\n'
|
||||
' "where": "N/A",\n'
|
||||
' "who": "Sebastian",\n'
|
||||
' "why": "MiniMax wraps JSON in code fences",\n'
|
||||
' "fact_kind": "event",\n'
|
||||
' "fact_type": "world",\n'
|
||||
' "entities": [{"text": "Sebastian"}, {"text": "Hindsight"}],\n'
|
||||
' "labels": {"source_type": "stated", "domain": ["infrastructure"]}\n'
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
result = _strip_code_fences(content)
|
||||
assert not result.startswith("```")
|
||||
assert not result.endswith("```")
|
||||
# Should be valid JSON
|
||||
import json
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert len(parsed["facts"]) == 1
|
||||
assert parsed["facts"][0]["who"] == "Sebastian"
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Unit tests for ValidationResult.accept_with() enrichment (PR #639).
|
||||
|
||||
These tests verify:
|
||||
1. The accept_with() factory creates an accepted result with the correct enrichment fields.
|
||||
2. The engine applies enrichment to retain contents and recall tags/tag_groups.
|
||||
3. RecallContext carries tags/tags_match/tag_groups so validators can read filter state.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.extensions import (
|
||||
OperationValidatorExtension,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RetainContext,
|
||||
ValidationResult,
|
||||
)
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure unit tests for ValidationResult factory methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationResultAcceptWith:
|
||||
"""Unit tests for the accept_with() factory — no DB needed."""
|
||||
|
||||
def test_accept_is_allowed_with_no_enrichment(self):
|
||||
result = ValidationResult.accept()
|
||||
assert result.allowed is True
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
assert result.tags_match is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_contents(self):
|
||||
contents = [{"content": "enriched text", "tags": ["injected"]}]
|
||||
result = ValidationResult.accept_with(contents=contents)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags(self):
|
||||
result = ValidationResult.accept_with(tags=["alpha", "beta"])
|
||||
assert result.allowed is True
|
||||
assert result.tags == ["alpha", "beta"]
|
||||
assert result.contents is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags_match(self):
|
||||
result = ValidationResult.accept_with(tags=["x"], tags_match="all")
|
||||
assert result.allowed is True
|
||||
assert result.tags_match == "all"
|
||||
|
||||
def test_accept_with_tag_groups(self):
|
||||
tag_groups = [{"tags": ["env:prod"], "match": "all"}]
|
||||
result = ValidationResult.accept_with(tag_groups=tag_groups)
|
||||
assert result.allowed is True
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_accept_with_all_fields(self):
|
||||
contents = [{"content": "c"}]
|
||||
tags = ["t1"]
|
||||
tag_groups = [{"tags": ["g1"]}]
|
||||
result = ValidationResult.accept_with(
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match="any",
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags == tags
|
||||
assert result.tags_match == "any"
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_reject_ignores_enrichment_fields(self):
|
||||
"""reject() always sets allowed=False and leaves enrichment fields at their defaults."""
|
||||
result = ValidationResult.reject("not allowed", status_code=403)
|
||||
assert result.allowed is False
|
||||
assert result.reason == "not allowed"
|
||||
assert result.status_code == 403
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
|
||||
def test_none_fields_mean_no_modification(self):
|
||||
"""None enrichment fields must not overwrite engine defaults."""
|
||||
result = ValidationResult.accept_with(tags=None, tag_groups=None)
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
# Engine should interpret None as "keep original" — we verify the contract here.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: engine applies enrichment from validator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ContentEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects a tag into every retain content item."""
|
||||
|
||||
def __init__(self, injected_tag: str):
|
||||
super().__init__({})
|
||||
self.injected_tag = injected_tag
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
enriched = []
|
||||
for item in ctx.contents:
|
||||
new_item = dict(item)
|
||||
new_item.setdefault("tags", [])
|
||||
new_item["tags"] = list(new_item["tags"]) + [self.injected_tag]
|
||||
enriched.append(new_item)
|
||||
return ValidationResult.accept_with(contents=enriched)
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _TagEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects tags into every recall operation."""
|
||||
|
||||
def __init__(self, forced_tags: list[str]):
|
||||
super().__init__({})
|
||||
self.forced_tags = forced_tags
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept_with(tags=self.forced_tags, tags_match="all")
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _RecallContextCapturingValidator(OperationValidatorExtension):
|
||||
"""Validator that captures the RecallContext for inspection."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({})
|
||||
self.captured: list[RecallContext] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
self.captured.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_content_enricher(memory):
|
||||
validator = _ContentEnrichingValidator(injected_tag="validator-injected")
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_tag_enricher(memory):
|
||||
validator = _TagEnrichingValidator(forced_tags=["forced-tag"])
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_recall_context_capture(memory):
|
||||
validator = _RecallContextCapturingValidator()
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
class TestRetainContentEnrichment:
|
||||
"""Engine applies enriched contents returned by validate_retain."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_contents_are_used_for_retain(self, memory_with_content_enricher):
|
||||
"""When validator returns accept_with(contents=...), engine uses those contents."""
|
||||
memory, validator = memory_with_content_enricher
|
||||
bank_id = "test-retain-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain without any tags — validator should inject "validator-injected"
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Alice is an engineer."}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# Retrieve facts tagged with the injected tag to confirm enrichment was applied
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
tags=["validator-injected"],
|
||||
request_context=ctx,
|
||||
)
|
||||
# The fact should be retrievable via the injected tag
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallTagEnrichment:
|
||||
"""Engine applies enriched tags returned by validate_recall."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_tags_filter_recall_results(self, memory_with_tag_enricher):
|
||||
"""When validator returns accept_with(tags=...), engine filters recall by those tags."""
|
||||
memory, validator = memory_with_tag_enricher
|
||||
bank_id = "test-recall-tag-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain one fact with the forced tag and one without
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Bob is a designer.", "tags": ["forced-tag"]}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# recall is called without tags but validator injects "forced-tag" + match=all
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Bob",
|
||||
request_context=ctx,
|
||||
)
|
||||
# Should still get a result — the injected tag matches the stored fact
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallContextContainsTagFields:
|
||||
"""RecallContext passed to validate_recall carries tag filter state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_carries_tags(self, memory_with_recall_context_capture):
|
||||
"""tags, tags_match, and tag_groups are present in RecallContext."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="test",
|
||||
tags=["env:prod"],
|
||||
tags_match="all",
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags == ["env:prod"]
|
||||
assert rc.tags_match == "all"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_tags_default_to_none(self, memory_with_recall_context_capture):
|
||||
"""When caller provides no tags, RecallContext.tags is None."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-no-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(bank_id=bank_id, query="test", request_context=ctx)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags is None
|
||||
@@ -58,10 +58,14 @@ async def pool(pg0_db_url):
|
||||
async def clean_operations(pool):
|
||||
"""Clean up async_operations table before and after tests."""
|
||||
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
yield
|
||||
# Clean after test
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
|
||||
|
||||
class TestBrokerTaskBackend:
|
||||
@@ -387,9 +391,7 @@ class TestWorkerPoller:
|
||||
"SELECT status, error_message, retry_count FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "failed", (
|
||||
f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
)
|
||||
assert row["status"] == "failed", f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
assert row["error_message"] is not None
|
||||
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
|
||||
|
||||
@@ -1265,7 +1267,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1294,7 +1298,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1364,7 +1370,9 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await _ensure_bank(pool, bank_id)
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1396,7 +1404,7 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
completed = 0
|
||||
while completed < 10 and len(tasks_started) < 10:
|
||||
# Release the next batch
|
||||
events_to_release = list(task_events.values())[completed:completed+3]
|
||||
events_to_release = list(task_events.values())[completed : completed + 3]
|
||||
for event in events_to_release:
|
||||
event.set()
|
||||
completed += len(events_to_release)
|
||||
@@ -1417,3 +1425,223 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await asyncio.wait_for(poll_task, timeout=1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestMarkFailedParentPropagation:
|
||||
"""Tests for _mark_failed parent propagation in WorkerPoller.
|
||||
|
||||
When a child retain operation fails via an unhandled exception, the memory
|
||||
engine's transaction is rolled back entirely — including any call to
|
||||
_maybe_update_parent_operation inside the engine. The poller's fallback
|
||||
_mark_failed must detect this and finalise the parent batch_retain itself.
|
||||
"""
|
||||
|
||||
async def _insert_op(
|
||||
self,
|
||||
pool,
|
||||
*,
|
||||
op_id: "uuid.UUID",
|
||||
bank_id: str,
|
||||
operation_type: str,
|
||||
status: str,
|
||||
result_metadata: dict | None = None,
|
||||
) -> None:
|
||||
meta_json = json.dumps(result_metadata if result_metadata is not None else {})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations
|
||||
(operation_id, bank_id, operation_type, status, result_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
status,
|
||||
meta_json,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_fails(self, pool, clean_operations):
|
||||
"""When the last pending child fails, parent batch_retain is marked failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
# Parent batch_retain still pending
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 already completed
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="completed",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
# child2 still processing — this is the one that will fail
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child2_id), "DB constraint violation", schema=None)
|
||||
|
||||
# child2 must be failed
|
||||
child2_row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1", child2_id
|
||||
)
|
||||
assert child2_row["status"] == "failed"
|
||||
assert "DB constraint violation" in child2_row["error_message"]
|
||||
|
||||
# parent must now be failed (all siblings done, at least one failed)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, clean_operations):
|
||||
"""When the only child fails, parent batch_retain becomes failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child_id), "unexpected error", schema=None)
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_does_not_finalise_parent_when_siblings_still_pending(self, pool, clean_operations):
|
||||
"""Parent is NOT updated while other siblings are still processing/pending."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 is the one failing
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
# child2 is still pending — not done yet
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="pending",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child1_id), "early failure", schema=None)
|
||||
|
||||
# child1 is failed
|
||||
child1_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child1_id)
|
||||
assert child1_row["status"] == "failed"
|
||||
|
||||
# parent must still be pending (child2 not done)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "pending", (
|
||||
f"Parent should remain 'pending' while siblings are outstanding, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_no_parent_is_safe(self, pool, clean_operations):
|
||||
"""Operations without a parent (no result_metadata parent_operation_id) fail cleanly."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
op_id = uuid.uuid4()
|
||||
await self._insert_op(pool, op_id=op_id, bank_id=bank_id, operation_type="retain", status="processing")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
# Must not raise
|
||||
await poller._mark_failed(str(op_id), "standalone failure", schema=None)
|
||||
|
||||
row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", op_id)
|
||||
assert row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unhandled_exception_via_execute_task_propagates_to_parent(self, pool, clean_operations):
|
||||
"""End-to-end: executor raises a plain exception, poller calls _mark_failed,
|
||||
which then resolves the parent batch_retain to failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
from hindsight_api.worker.poller import ClaimedTask
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
async def crashing_executor(task_dict):
|
||||
raise RuntimeError("Simulated DB constraint violation — transaction rolled back")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=crashing_executor)
|
||||
|
||||
task_dict = {"type": "retain", "operation_id": str(child_id), "bank_id": bank_id}
|
||||
claimed_task = ClaimedTask(operation_id=str(child_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
|
||||
child_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child_id)
|
||||
assert child_row["status"] == "failed"
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent batch_retain should be 'failed' after child fails via unhandled exception, "
|
||||
f"got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@@ -15,5 +15,11 @@ dependencies = [
|
||||
[tool.uv.sources]
|
||||
hindsight-api-slim = { workspace = true }
|
||||
|
||||
[project.scripts]
|
||||
hindsight-api = "hindsight_api.main:main"
|
||||
hindsight-worker = "hindsight_api.worker.main:main"
|
||||
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||
hindsight-admin = "hindsight_api.admin.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = []
|
||||
|
||||
@@ -601,6 +601,13 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Directive Methods ---
|
||||
|
||||
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
|
||||
|
||||
@@ -717,6 +717,13 @@ pub fn set_config(
|
||||
llm_model: Option<String>,
|
||||
llm_api_key: Option<String>,
|
||||
llm_base_url: Option<String>,
|
||||
retain_mission: Option<String>,
|
||||
retain_extraction_mode: Option<String>,
|
||||
observations_mission: Option<String>,
|
||||
reflect_mission: Option<String>,
|
||||
disposition_skepticism: Option<i64>,
|
||||
disposition_literalism: Option<i64>,
|
||||
disposition_empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -736,9 +743,30 @@ pub fn set_config(
|
||||
if let Some(base_url) = llm_base_url {
|
||||
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
|
||||
}
|
||||
if let Some(mission) = retain_mission {
|
||||
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mode) = retain_extraction_mode {
|
||||
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
|
||||
}
|
||||
if let Some(mission) = observations_mission {
|
||||
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mission) = reflect_mission {
|
||||
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(skepticism) = disposition_skepticism {
|
||||
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
|
||||
}
|
||||
if let Some(literalism) = disposition_literalism {
|
||||
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
|
||||
}
|
||||
if let Some(empathy) = disposition_empathy {
|
||||
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --observations-mission, or other flags".to_string()));
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
|
||||
@@ -149,11 +149,12 @@ pub fn update(
|
||||
directive_id: &str,
|
||||
name: Option<String>,
|
||||
content: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && content.is_none() {
|
||||
anyhow::bail!("At least one of --name or --content must be provided");
|
||||
if name.is_none() && content.is_none() && is_active.is_none() {
|
||||
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -165,7 +166,7 @@ pub fn update(
|
||||
let request = types::UpdateDirectiveRequest {
|
||||
name,
|
||||
content,
|
||||
is_active: None,
|
||||
is_active,
|
||||
priority: None,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
@@ -363,6 +363,9 @@ impl App {
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let result = client.reflect(&bank_id, &request, false)
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
@@ -43,6 +43,16 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to parse tags_match string to TagsMatch enum
|
||||
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
|
||||
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
|
||||
"all" => TagsMatch::All,
|
||||
"any_strict" => TagsMatch::AnyStrict,
|
||||
"all_strict" => TagsMatch::AllStrict,
|
||||
_ => TagsMatch::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
@@ -250,6 +260,8 @@ pub fn recall(
|
||||
trace: bool,
|
||||
include_chunks: bool,
|
||||
chunk_max_tokens: i64,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -280,8 +292,8 @@ pub fn recall(
|
||||
trace,
|
||||
query_timestamp: None,
|
||||
include,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
};
|
||||
|
||||
@@ -312,6 +324,9 @@ pub fn reflect(
|
||||
context: Option<String>,
|
||||
max_tokens: Option<i64>,
|
||||
schema_path: Option<PathBuf>,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
include_facts: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -332,16 +347,28 @@ pub fn reflect(
|
||||
None
|
||||
};
|
||||
|
||||
let include = if include_facts {
|
||||
Some(ReflectIncludeOptions {
|
||||
facts: Some(FactsIncludeOptions(serde_json::Map::new())),
|
||||
tool_calls: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = ReflectRequest {
|
||||
query,
|
||||
budget: Some(parse_budget(&budget)),
|
||||
context,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
include: None,
|
||||
include,
|
||||
response_schema,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let response = client.reflect(agent_id, &request, verbose);
|
||||
|
||||
@@ -272,6 +272,55 @@ pub fn refresh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the change history of a mental model
|
||||
pub fn history(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental model history..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model_history(bank_id, mental_model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(history) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("History: {}", mental_model_id));
|
||||
|
||||
if let Some(entries) = history.as_array() {
|
||||
if entries.is_empty() {
|
||||
println!(" {}", ui::dim("No history entries found."));
|
||||
} else {
|
||||
for entry in entries {
|
||||
let changed_at = entry.get("changed_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let previous = entry.get("previous_content").and_then(|v| v.as_str()).unwrap_or("(none)");
|
||||
println!(" {} {}", ui::dim("Changed at:"), changed_at);
|
||||
let preview: String = previous.chars().take(80).collect();
|
||||
let ellipsis = if previous.len() > 80 { "..." } else { "" };
|
||||
println!(" {} {}{}", ui::dim("Previous:"), ui::dim(&preview), ellipsis);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&history, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to print mental model details
|
||||
fn print_mental_model_detail(mental_model: &types::MentalModelResponse) {
|
||||
ui::print_section_header(&mental_model.name);
|
||||
|
||||
@@ -310,6 +310,34 @@ enum BankCommands {
|
||||
/// LLM base URL override
|
||||
#[arg(long)]
|
||||
llm_base_url: Option<String>,
|
||||
|
||||
/// Retain mission: what to focus on during fact extraction
|
||||
#[arg(long)]
|
||||
retain_mission: Option<String>,
|
||||
|
||||
/// Retain extraction mode (concise, verbose, custom)
|
||||
#[arg(long)]
|
||||
retain_extraction_mode: Option<String>,
|
||||
|
||||
/// Observations mission: what to synthesize into durable observations
|
||||
#[arg(long)]
|
||||
observations_mission: Option<String>,
|
||||
|
||||
/// Reflect mission: first-person identity for reflect operations
|
||||
#[arg(long)]
|
||||
reflect_mission: Option<String>,
|
||||
|
||||
/// Disposition skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_skepticism: Option<i64>,
|
||||
|
||||
/// Disposition literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_literalism: Option<i64>,
|
||||
|
||||
/// Disposition empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Reset bank configuration to defaults (remove all overrides)
|
||||
@@ -387,6 +415,14 @@ enum MemoryCommands {
|
||||
/// Maximum tokens for chunks (only used with --include-chunks)
|
||||
#[arg(long, default_value = "8192")]
|
||||
chunk_max_tokens: i64,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// Generate answers using bank identity (reflect/reasoning)
|
||||
@@ -412,6 +448,18 @@ enum MemoryCommands {
|
||||
/// Path to JSON schema file for structured output
|
||||
#[arg(short = 's', long)]
|
||||
schema: Option<PathBuf>,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
|
||||
/// Include source facts (based_on) in the response
|
||||
#[arg(long)]
|
||||
include_facts: bool,
|
||||
},
|
||||
|
||||
/// Store (retain) a single memory
|
||||
@@ -678,6 +726,15 @@ enum MentalModelCommands {
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
|
||||
/// Get the change history of a mental model
|
||||
History {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -724,6 +781,10 @@ enum DirectiveCommands {
|
||||
/// New content
|
||||
#[arg(long)]
|
||||
content: Option<String>,
|
||||
|
||||
/// Enable or disable the directive
|
||||
#[arg(long)]
|
||||
is_active: Option<bool>,
|
||||
},
|
||||
|
||||
/// Delete a directive
|
||||
@@ -821,8 +882,8 @@ fn run() -> Result<()> {
|
||||
BankCommands::Config { bank_id, overrides_only } => {
|
||||
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
|
||||
}
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::ResetConfig { bank_id, yes } => {
|
||||
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
|
||||
@@ -837,11 +898,11 @@ fn run() -> Result<()> {
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, verbose, output_format)
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => {
|
||||
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
|
||||
@@ -930,6 +991,9 @@ fn run() -> Result<()> {
|
||||
MentalModelCommands::Refresh { bank_id, mental_model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::History { bank_id, mental_model_id } => {
|
||||
commands::mental_model::history(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Directive commands
|
||||
@@ -943,8 +1007,8 @@ fn run() -> Result<()> {
|
||||
DirectiveCommands::Create { bank_id, name, content } => {
|
||||
commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format)
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content, is_active } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Delete { bank_id, directive_id, yes } => {
|
||||
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
|
||||
|
||||
@@ -4074,6 +4074,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4089,6 +4096,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4115,6 +4129,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4169,6 +4190,13 @@ components:
|
||||
description: Trigger settings for a mental model.
|
||||
example:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
properties:
|
||||
refresh_after_consolidation:
|
||||
default: false
|
||||
@@ -4176,6 +4204,26 @@ components:
|
||||
\ (real-time mode)"
|
||||
title: Refresh After Consolidation
|
||||
type: boolean
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
title: MentalModelTrigger
|
||||
OperationResponse:
|
||||
description: Response model for a single async operation.
|
||||
@@ -4684,6 +4732,26 @@ components:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
nullable: true
|
||||
type: array
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- query
|
||||
title: ReflectRequest
|
||||
|
||||
@@ -21,6 +21,10 @@ var _ MappedNullable = &MentalModelTrigger{}
|
||||
type MentalModelTrigger struct {
|
||||
// If true, refresh this mental model after observations consolidation (real-time mode)
|
||||
RefreshAfterConsolidation *bool `json:"refresh_after_consolidation,omitempty"`
|
||||
FactTypes []string `json:"fact_types,omitempty"`
|
||||
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
|
||||
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
|
||||
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
|
||||
}
|
||||
|
||||
// NewMentalModelTrigger instantiates a new MentalModelTrigger object
|
||||
@@ -31,6 +35,8 @@ func NewMentalModelTrigger() *MentalModelTrigger {
|
||||
this := MentalModelTrigger{}
|
||||
var refreshAfterConsolidation bool = false
|
||||
this.RefreshAfterConsolidation = &refreshAfterConsolidation
|
||||
var excludeMentalModels bool = false
|
||||
this.ExcludeMentalModels = &excludeMentalModels
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -41,6 +47,8 @@ func NewMentalModelTriggerWithDefaults() *MentalModelTrigger {
|
||||
this := MentalModelTrigger{}
|
||||
var refreshAfterConsolidation bool = false
|
||||
this.RefreshAfterConsolidation = &refreshAfterConsolidation
|
||||
var excludeMentalModels bool = false
|
||||
this.ExcludeMentalModels = &excludeMentalModels
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -76,6 +84,104 @@ func (o *MentalModelTrigger) SetRefreshAfterConsolidation(v bool) {
|
||||
o.RefreshAfterConsolidation = &v
|
||||
}
|
||||
|
||||
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *MentalModelTrigger) GetFactTypes() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.FactTypes
|
||||
}
|
||||
|
||||
// GetFactTypesOk returns a tuple with the FactTypes field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *MentalModelTrigger) GetFactTypesOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.FactTypes) {
|
||||
return nil, false
|
||||
}
|
||||
return o.FactTypes, true
|
||||
}
|
||||
|
||||
// HasFactTypes returns a boolean if a field has been set.
|
||||
func (o *MentalModelTrigger) HasFactTypes() bool {
|
||||
if o != nil && !IsNil(o.FactTypes) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
|
||||
func (o *MentalModelTrigger) SetFactTypes(v []string) {
|
||||
o.FactTypes = v
|
||||
}
|
||||
|
||||
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
|
||||
func (o *MentalModelTrigger) GetExcludeMentalModels() bool {
|
||||
if o == nil || IsNil(o.ExcludeMentalModels) {
|
||||
var ret bool
|
||||
return ret
|
||||
}
|
||||
return *o.ExcludeMentalModels
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *MentalModelTrigger) GetExcludeMentalModelsOk() (*bool, bool) {
|
||||
if o == nil || IsNil(o.ExcludeMentalModels) {
|
||||
return nil, false
|
||||
}
|
||||
return o.ExcludeMentalModels, true
|
||||
}
|
||||
|
||||
// HasExcludeMentalModels returns a boolean if a field has been set.
|
||||
func (o *MentalModelTrigger) HasExcludeMentalModels() bool {
|
||||
if o != nil && !IsNil(o.ExcludeMentalModels) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
|
||||
func (o *MentalModelTrigger) SetExcludeMentalModels(v bool) {
|
||||
o.ExcludeMentalModels = &v
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *MentalModelTrigger) GetExcludeMentalModelIds() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.ExcludeMentalModelIds
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *MentalModelTrigger) GetExcludeMentalModelIdsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.ExcludeMentalModelIds) {
|
||||
return nil, false
|
||||
}
|
||||
return o.ExcludeMentalModelIds, true
|
||||
}
|
||||
|
||||
// HasExcludeMentalModelIds returns a boolean if a field has been set.
|
||||
func (o *MentalModelTrigger) HasExcludeMentalModelIds() bool {
|
||||
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
|
||||
func (o *MentalModelTrigger) SetExcludeMentalModelIds(v []string) {
|
||||
o.ExcludeMentalModelIds = v
|
||||
}
|
||||
|
||||
func (o MentalModelTrigger) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -89,6 +195,15 @@ func (o MentalModelTrigger) ToMap() (map[string]interface{}, error) {
|
||||
if !IsNil(o.RefreshAfterConsolidation) {
|
||||
toSerialize["refresh_after_consolidation"] = o.RefreshAfterConsolidation
|
||||
}
|
||||
if o.FactTypes != nil {
|
||||
toSerialize["fact_types"] = o.FactTypes
|
||||
}
|
||||
if !IsNil(o.ExcludeMentalModels) {
|
||||
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
|
||||
}
|
||||
if o.ExcludeMentalModelIds != nil {
|
||||
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ type ReflectRequest struct {
|
||||
// How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).
|
||||
TagsMatch *string `json:"tags_match,omitempty"`
|
||||
TagGroups []RecallRequestTagGroupsInner `json:"tag_groups,omitempty"`
|
||||
FactTypes []string `json:"fact_types,omitempty"`
|
||||
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
|
||||
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
|
||||
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
|
||||
}
|
||||
|
||||
type _ReflectRequest ReflectRequest
|
||||
@@ -48,6 +52,8 @@ func NewReflectRequest(query string) *ReflectRequest {
|
||||
this.MaxTokens = &maxTokens
|
||||
var tagsMatch string = "any"
|
||||
this.TagsMatch = &tagsMatch
|
||||
var excludeMentalModels bool = false
|
||||
this.ExcludeMentalModels = &excludeMentalModels
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -60,6 +66,8 @@ func NewReflectRequestWithDefaults() *ReflectRequest {
|
||||
this.MaxTokens = &maxTokens
|
||||
var tagsMatch string = "any"
|
||||
this.TagsMatch = &tagsMatch
|
||||
var excludeMentalModels bool = false
|
||||
this.ExcludeMentalModels = &excludeMentalModels
|
||||
return &this
|
||||
}
|
||||
|
||||
@@ -356,6 +364,104 @@ func (o *ReflectRequest) SetTagGroups(v []RecallRequestTagGroupsInner) {
|
||||
o.TagGroups = v
|
||||
}
|
||||
|
||||
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *ReflectRequest) GetFactTypes() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.FactTypes
|
||||
}
|
||||
|
||||
// GetFactTypesOk returns a tuple with the FactTypes field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *ReflectRequest) GetFactTypesOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.FactTypes) {
|
||||
return nil, false
|
||||
}
|
||||
return o.FactTypes, true
|
||||
}
|
||||
|
||||
// HasFactTypes returns a boolean if a field has been set.
|
||||
func (o *ReflectRequest) HasFactTypes() bool {
|
||||
if o != nil && !IsNil(o.FactTypes) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
|
||||
func (o *ReflectRequest) SetFactTypes(v []string) {
|
||||
o.FactTypes = v
|
||||
}
|
||||
|
||||
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
|
||||
func (o *ReflectRequest) GetExcludeMentalModels() bool {
|
||||
if o == nil || IsNil(o.ExcludeMentalModels) {
|
||||
var ret bool
|
||||
return ret
|
||||
}
|
||||
return *o.ExcludeMentalModels
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *ReflectRequest) GetExcludeMentalModelsOk() (*bool, bool) {
|
||||
if o == nil || IsNil(o.ExcludeMentalModels) {
|
||||
return nil, false
|
||||
}
|
||||
return o.ExcludeMentalModels, true
|
||||
}
|
||||
|
||||
// HasExcludeMentalModels returns a boolean if a field has been set.
|
||||
func (o *ReflectRequest) HasExcludeMentalModels() bool {
|
||||
if o != nil && !IsNil(o.ExcludeMentalModels) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
|
||||
func (o *ReflectRequest) SetExcludeMentalModels(v bool) {
|
||||
o.ExcludeMentalModels = &v
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *ReflectRequest) GetExcludeMentalModelIds() []string {
|
||||
if o == nil {
|
||||
var ret []string
|
||||
return ret
|
||||
}
|
||||
return o.ExcludeMentalModelIds
|
||||
}
|
||||
|
||||
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *ReflectRequest) GetExcludeMentalModelIdsOk() ([]string, bool) {
|
||||
if o == nil || IsNil(o.ExcludeMentalModelIds) {
|
||||
return nil, false
|
||||
}
|
||||
return o.ExcludeMentalModelIds, true
|
||||
}
|
||||
|
||||
// HasExcludeMentalModelIds returns a boolean if a field has been set.
|
||||
func (o *ReflectRequest) HasExcludeMentalModelIds() bool {
|
||||
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
|
||||
func (o *ReflectRequest) SetExcludeMentalModelIds(v []string) {
|
||||
o.ExcludeMentalModelIds = v
|
||||
}
|
||||
|
||||
func (o ReflectRequest) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -391,6 +497,15 @@ func (o ReflectRequest) ToMap() (map[string]interface{}, error) {
|
||||
if o.TagGroups != nil {
|
||||
toSerialize["tag_groups"] = o.TagGroups
|
||||
}
|
||||
if o.FactTypes != nil {
|
||||
toSerialize["fact_types"] = o.FactTypes
|
||||
}
|
||||
if !IsNil(o.ExcludeMentalModels) {
|
||||
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
|
||||
}
|
||||
if o.ExcludeMentalModelIds != nil {
|
||||
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -792,6 +792,7 @@ class Hindsight:
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
trigger: dict[str, Any] | None = None,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Create a mental model (runs reflect in background).
|
||||
@@ -803,6 +804,7 @@ class Hindsight:
|
||||
tags: Optional tags for filtering during retrieval
|
||||
max_tokens: Optional maximum tokens for the mental model content
|
||||
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
|
||||
id: Optional custom ID for the mental model (alphanumeric lowercase with hyphens)
|
||||
|
||||
Returns:
|
||||
CreateMentalModelResponse with operation_id
|
||||
@@ -814,6 +816,7 @@ class Hindsight:
|
||||
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
|
||||
|
||||
request_obj = create_mental_model_request.CreateMentalModelRequest(
|
||||
id=id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
tags=tags,
|
||||
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
@@ -27,7 +27,21 @@ class MentalModelTrigger(BaseModel):
|
||||
Trigger settings for a mental model.
|
||||
""" # noqa: E501
|
||||
refresh_after_consolidation: Optional[StrictBool] = Field(default=False, description="If true, refresh this mental model after observations consolidation (real-time mode)")
|
||||
__properties: ClassVar[List[str]] = ["refresh_after_consolidation"]
|
||||
fact_types: Optional[List[StrictStr]] = None
|
||||
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
|
||||
exclude_mental_model_ids: Optional[List[StrictStr]] = None
|
||||
__properties: ClassVar[List[str]] = ["refresh_after_consolidation", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
|
||||
|
||||
@field_validator('fact_types')
|
||||
def fact_types_validate_enum(cls, value):
|
||||
"""Validates the enum"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
for i in value:
|
||||
if i not in set(['world', 'experience', 'observation']):
|
||||
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
|
||||
return value
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -68,6 +82,16 @@ class MentalModelTrigger(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if fact_types (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.fact_types is None and "fact_types" in self.model_fields_set:
|
||||
_dict['fact_types'] = None
|
||||
|
||||
# set to None if exclude_mental_model_ids (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
|
||||
_dict['exclude_mental_model_ids'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -80,7 +104,10 @@ class MentalModelTrigger(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False
|
||||
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False,
|
||||
"fact_types": obj.get("fact_types"),
|
||||
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
|
||||
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.budget import Budget
|
||||
from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner
|
||||
@@ -38,7 +38,10 @@ class ReflectRequest(BaseModel):
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
tags_match: Optional[StrictStr] = Field(default='any', description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).")
|
||||
tag_groups: Optional[List[RecallRequestTagGroupsInner]] = None
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups"]
|
||||
fact_types: Optional[List[StrictStr]] = None
|
||||
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
|
||||
exclude_mental_model_ids: Optional[List[StrictStr]] = None
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
|
||||
|
||||
@field_validator('tags_match')
|
||||
def tags_match_validate_enum(cls, value):
|
||||
@@ -50,6 +53,17 @@ class ReflectRequest(BaseModel):
|
||||
raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')")
|
||||
return value
|
||||
|
||||
@field_validator('fact_types')
|
||||
def fact_types_validate_enum(cls, value):
|
||||
"""Validates the enum"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
for i in value:
|
||||
if i not in set(['world', 'experience', 'observation']):
|
||||
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
|
||||
return value
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
@@ -119,6 +133,16 @@ class ReflectRequest(BaseModel):
|
||||
if self.tag_groups is None and "tag_groups" in self.model_fields_set:
|
||||
_dict['tag_groups'] = None
|
||||
|
||||
# set to None if fact_types (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.fact_types is None and "fact_types" in self.model_fields_set:
|
||||
_dict['fact_types'] = None
|
||||
|
||||
# set to None if exclude_mental_model_ids (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
|
||||
_dict['exclude_mental_model_ids'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -139,7 +163,10 @@ class ReflectRequest(BaseModel):
|
||||
"response_schema": obj.get("response_schema"),
|
||||
"tags": obj.get("tags"),
|
||||
"tags_match": obj.get("tags_match") if obj.get("tags_match") is not None else 'any',
|
||||
"tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None
|
||||
"tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None,
|
||||
"fact_types": obj.get("fact_types"),
|
||||
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
|
||||
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1331,6 +1331,24 @@ export type MentalModelTrigger = {
|
||||
* If true, refresh this mental model after observations consolidation (real-time mode)
|
||||
*/
|
||||
refresh_after_consolidation?: boolean;
|
||||
/**
|
||||
* Fact Types
|
||||
*
|
||||
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
|
||||
*/
|
||||
fact_types?: Array<"world" | "experience" | "observation"> | null;
|
||||
/**
|
||||
* Exclude Mental Models
|
||||
*
|
||||
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
|
||||
*/
|
||||
exclude_mental_models?: boolean;
|
||||
/**
|
||||
* Exclude Mental Model Ids
|
||||
*
|
||||
* Exclude specific mental models by ID from the reflect loop.
|
||||
*/
|
||||
exclude_mental_model_ids?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1825,6 +1843,24 @@ export type ReflectRequest = {
|
||||
tag_groups?: Array<
|
||||
TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot
|
||||
> | null;
|
||||
/**
|
||||
* Fact Types
|
||||
*
|
||||
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
|
||||
*/
|
||||
fact_types?: Array<"world" | "experience" | "observation"> | null;
|
||||
/**
|
||||
* Exclude Mental Models
|
||||
*
|
||||
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
|
||||
*/
|
||||
exclude_mental_models?: boolean;
|
||||
/**
|
||||
* Exclude Mental Model Ids
|
||||
*
|
||||
* Exclude specific mental models by ID from the reflect loop.
|
||||
*/
|
||||
exclude_mental_model_ids?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -628,6 +628,7 @@ export class HindsightClient {
|
||||
name: string,
|
||||
sourceQuery: string,
|
||||
options?: {
|
||||
id?: string;
|
||||
tags?: string[];
|
||||
maxTokens?: number;
|
||||
trigger?: { refreshAfterConsolidation?: boolean };
|
||||
@@ -637,6 +638,7 @@ export class HindsightClient {
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
id: options?.id,
|
||||
name,
|
||||
source_query: sourceQuery,
|
||||
tags: options?.tags,
|
||||
@@ -726,6 +728,18 @@ export class HindsightClient {
|
||||
throw new Error(`deleteMentalModel failed: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the change history of a mental model.
|
||||
*/
|
||||
async getMentalModelHistory(bankId: string, mentalModelId: string): Promise<any> {
|
||||
const response = await sdk.getMentalModelHistory({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, mental_model_id: mentalModelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'getMentalModelHistory');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { DATAPLANE_URL } from "@/lib/hindsight-client";
|
||||
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -25,6 +25,7 @@ export async function POST(request: NextRequest) {
|
||||
// Forward the form data to the dataplane
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getDataplaneHeaders(),
|
||||
body: formData,
|
||||
// Don't set Content-Type - let fetch handle multipart boundary
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ export async function POST(request: NextRequest) {
|
||||
tags,
|
||||
tags_match,
|
||||
max_tokens,
|
||||
fact_types,
|
||||
exclude_mental_models,
|
||||
exclude_mental_model_ids,
|
||||
} = body;
|
||||
|
||||
const requestBody: any = {
|
||||
@@ -22,6 +25,9 @@ export async function POST(request: NextRequest) {
|
||||
tags,
|
||||
tags_match,
|
||||
max_tokens: max_tokens || undefined,
|
||||
fact_types: fact_types || undefined,
|
||||
exclude_mental_models: exclude_mental_models || undefined,
|
||||
exclude_mental_model_ids: exclude_mental_model_ids || undefined,
|
||||
};
|
||||
|
||||
// Add include options if specified
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type FactType = "world" | "experience" | "observation";
|
||||
|
||||
export const ALL_FACT_TYPES: FactType[] = ["world", "experience", "observation"];
|
||||
|
||||
const FACT_TYPE_CONFIG: Record<
|
||||
FactType,
|
||||
{ label: string; active: string; inactive: string; dot: string }
|
||||
> = {
|
||||
world: {
|
||||
label: "World",
|
||||
active: "bg-blue-500/15 text-blue-700 border-blue-400 dark:text-blue-300 dark:border-blue-500",
|
||||
inactive:
|
||||
"border-border text-muted-foreground hover:border-blue-300 hover:text-blue-600 dark:hover:text-blue-400",
|
||||
dot: "bg-blue-500",
|
||||
},
|
||||
experience: {
|
||||
label: "Experience",
|
||||
active:
|
||||
"bg-emerald-500/15 text-emerald-700 border-emerald-400 dark:text-emerald-300 dark:border-emerald-500",
|
||||
inactive:
|
||||
"border-border text-muted-foreground hover:border-emerald-300 hover:text-emerald-600 dark:hover:text-emerald-400",
|
||||
dot: "bg-emerald-500",
|
||||
},
|
||||
observation: {
|
||||
label: "Observation",
|
||||
active:
|
||||
"bg-amber-500/15 text-amber-700 border-amber-400 dark:text-amber-300 dark:border-amber-500",
|
||||
inactive:
|
||||
"border-border text-muted-foreground hover:border-amber-300 hover:text-amber-600 dark:hover:text-amber-400",
|
||||
dot: "bg-amber-500",
|
||||
},
|
||||
};
|
||||
|
||||
function FactTypePill({
|
||||
ft,
|
||||
active,
|
||||
onToggle,
|
||||
}: {
|
||||
ft: FactType;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const cfg = FACT_TYPE_CONFIG[ft];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
active ? cfg.active : cfg.inactive
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 rounded-full", active ? cfg.dot : "bg-muted-foreground/50")}
|
||||
/>
|
||||
{cfg.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline pill-toggle fact-type filter for filter bars.
|
||||
* An empty selection means "all types included".
|
||||
*/
|
||||
export function FactTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
label = "Fact types:",
|
||||
}: {
|
||||
value: FactType[];
|
||||
onChange: (next: FactType[]) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const toggle = (ft: FactType) =>
|
||||
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{label && <span className="text-sm font-medium text-muted-foreground">{label}</span>}
|
||||
<div className="flex gap-1.5">
|
||||
{ALL_FACT_TYPES.map((ft) => (
|
||||
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pill-toggle group for use inside forms/dialogs.
|
||||
*/
|
||||
export function FactTypeCheckboxGroup({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FactType[];
|
||||
onChange: (next: FactType[]) => void;
|
||||
}) {
|
||||
const toggle = (ft: FactType) =>
|
||||
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_FACT_TYPES.map((ft) => (
|
||||
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { useBank } from "@/lib/bank-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { FactType, FactTypeCheckboxGroup } from "@/components/fact-type-filter";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -86,6 +88,9 @@ interface MentalModel {
|
||||
max_tokens: number;
|
||||
trigger: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
last_refreshed_at: string;
|
||||
created_at: string;
|
||||
@@ -593,6 +598,9 @@ function CreateMentalModelDialog({
|
||||
maxTokens: "2048",
|
||||
tags: "",
|
||||
autoRefresh: false,
|
||||
factTypes: [] as Array<"world" | "experience" | "observation">,
|
||||
excludeMentalModels: false,
|
||||
excludeMentalModelIds: "",
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -608,13 +616,23 @@ function CreateMentalModelDialog({
|
||||
const maxTokens = parseInt(form.maxTokens) || 2048;
|
||||
|
||||
// Submit mental model creation - content will be generated in background
|
||||
const excludeIds = form.excludeMentalModelIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
await client.createMentalModel(currentBank, {
|
||||
id: form.id.trim() || undefined,
|
||||
name: form.name.trim(),
|
||||
source_query: form.sourceQuery.trim(),
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
max_tokens: maxTokens,
|
||||
trigger: { refresh_after_consolidation: form.autoRefresh },
|
||||
trigger: {
|
||||
refresh_after_consolidation: form.autoRefresh,
|
||||
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
|
||||
exclude_mental_models: form.excludeMentalModels || undefined,
|
||||
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setForm({
|
||||
@@ -624,6 +642,9 @@ function CreateMentalModelDialog({
|
||||
maxTokens: "2048",
|
||||
tags: "",
|
||||
autoRefresh: false,
|
||||
factTypes: [],
|
||||
excludeMentalModels: false,
|
||||
excludeMentalModelIds: "",
|
||||
});
|
||||
onCreated();
|
||||
} catch (error) {
|
||||
@@ -645,6 +666,9 @@ function CreateMentalModelDialog({
|
||||
maxTokens: "2048",
|
||||
tags: "",
|
||||
autoRefresh: false,
|
||||
factTypes: [],
|
||||
excludeMentalModels: false,
|
||||
excludeMentalModelIds: "",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
@@ -659,80 +683,111 @@ function CreateMentalModelDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
ID <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.id}
|
||||
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
||||
placeholder="e.g., team-communication"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom ID for the mental model. If not provided, a UUID will be generated.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Name *</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Team Communication Preferences"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Source Query *</label>
|
||||
<Input
|
||||
value={form.sourceQuery}
|
||||
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
|
||||
placeholder="e.g., How does the team prefer to communicate?"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This query will be run to generate the initial content, and re-run when you refresh.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Max Tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
|
||||
placeholder="2048"
|
||||
min="256"
|
||||
max="8192"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum tokens for the generated response (256-8192).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Tags <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
placeholder="e.g., project-x, team-alpha (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-refresh"
|
||||
checked={form.autoRefresh}
|
||||
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
|
||||
/>
|
||||
<label
|
||||
htmlFor="auto-refresh"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Auto-refresh after consolidation
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2 ml-6">
|
||||
Automatically refresh this mental model when memories are consolidated.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue="general" className="py-2">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="general" className="flex-1">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" className="flex-1">
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">ID</label>
|
||||
<Input
|
||||
value={form.id}
|
||||
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
||||
placeholder="e.g., team-communication"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Name *</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Team Communication Preferences"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Source Query *</label>
|
||||
<Input
|
||||
value={form.sourceQuery}
|
||||
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
|
||||
placeholder="e.g., How does the team prefer to communicate?"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Max Tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
|
||||
placeholder="2048"
|
||||
min="256"
|
||||
max="8192"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="options" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Tags</label>
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
placeholder="e.g., project-x, team-alpha (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-refresh"
|
||||
checked={form.autoRefresh}
|
||||
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
|
||||
/>
|
||||
<label
|
||||
htmlFor="auto-refresh"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Auto-refresh after consolidation
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground">Fact Types</label>
|
||||
<FactTypeCheckboxGroup
|
||||
value={form.factTypes}
|
||||
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="exclude-mental-models"
|
||||
checked={form.excludeMentalModels}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm({ ...form, excludeMentalModels: checked === true })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="exclude-mental-models"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Exclude all mental models
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Exclude Mental Model IDs
|
||||
</label>
|
||||
<Input
|
||||
value={form.excludeMentalModelIds}
|
||||
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
|
||||
placeholder="e.g., model-a, model-b (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={creating}>
|
||||
@@ -776,6 +831,12 @@ function UpdateMentalModelDialog({
|
||||
maxTokens: String(mentalModel.max_tokens || 2048),
|
||||
tags: mentalModel.tags.join(", "),
|
||||
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
|
||||
factTypes:
|
||||
(mentalModel.trigger?.fact_types as
|
||||
| Array<"world" | "experience" | "observation">
|
||||
| undefined) || [],
|
||||
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
|
||||
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
|
||||
});
|
||||
|
||||
// Reset form when mental model changes or dialog opens
|
||||
@@ -787,6 +848,12 @@ function UpdateMentalModelDialog({
|
||||
maxTokens: String(mentalModel.max_tokens || 2048),
|
||||
tags: mentalModel.tags.join(", "),
|
||||
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
|
||||
factTypes:
|
||||
(mentalModel.trigger?.fact_types as
|
||||
| Array<"world" | "experience" | "observation">
|
||||
| undefined) || [],
|
||||
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
|
||||
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
|
||||
});
|
||||
}
|
||||
}, [open, mentalModel]);
|
||||
@@ -803,12 +870,22 @@ function UpdateMentalModelDialog({
|
||||
|
||||
const maxTokens = parseInt(form.maxTokens) || 2048;
|
||||
|
||||
const excludeIds = form.excludeMentalModelIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const updated = await client.updateMentalModel(currentBank, mentalModel.id, {
|
||||
name: form.name.trim(),
|
||||
source_query: form.sourceQuery.trim(),
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
max_tokens: maxTokens,
|
||||
trigger: { refresh_after_consolidation: form.autoRefresh },
|
||||
trigger: {
|
||||
refresh_after_consolidation: form.autoRefresh,
|
||||
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
|
||||
exclude_mental_models: form.excludeMentalModels || undefined,
|
||||
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUpdated(updated);
|
||||
@@ -830,72 +907,107 @@ function UpdateMentalModelDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<Input value={mentalModel.id} disabled className="bg-muted" />
|
||||
<p className="text-xs text-muted-foreground">ID cannot be changed after creation.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Name *</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Team Communication Preferences"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Source Query *</label>
|
||||
<Input
|
||||
value={form.sourceQuery}
|
||||
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
|
||||
placeholder="e.g., How does the team prefer to communicate?"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This query will be run to generate the initial content, and re-run when you refresh.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Max Tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
|
||||
placeholder="2048"
|
||||
min="256"
|
||||
max="8192"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum tokens for the generated response (256-8192).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Tags <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
placeholder="e.g., project-x, team-alpha (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="update-auto-refresh"
|
||||
checked={form.autoRefresh}
|
||||
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
|
||||
/>
|
||||
<label
|
||||
htmlFor="update-auto-refresh"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Auto-refresh after consolidation
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2 ml-6">
|
||||
Automatically refresh this mental model when memories are consolidated.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue="general" className="py-2">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="general" className="flex-1">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="options" className="flex-1">
|
||||
Options
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">ID</label>
|
||||
<Input value={mentalModel.id} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Name *</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Team Communication Preferences"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Source Query *</label>
|
||||
<Input
|
||||
value={form.sourceQuery}
|
||||
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
|
||||
placeholder="e.g., How does the team prefer to communicate?"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Max Tokens</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxTokens}
|
||||
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
|
||||
placeholder="2048"
|
||||
min="256"
|
||||
max="8192"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="options" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Tags</label>
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
placeholder="e.g., project-x, team-alpha (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="update-auto-refresh"
|
||||
checked={form.autoRefresh}
|
||||
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
|
||||
/>
|
||||
<label
|
||||
htmlFor="update-auto-refresh"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Auto-refresh after consolidation
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground">Fact Types</label>
|
||||
<FactTypeCheckboxGroup
|
||||
value={form.factTypes}
|
||||
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="update-exclude-mental-models"
|
||||
checked={form.excludeMentalModels}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm({ ...form, excludeMentalModels: checked === true })
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="update-exclude-mental-models"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Exclude all mental models
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Exclude Mental Model IDs
|
||||
</label>
|
||||
<Input
|
||||
value={form.excludeMentalModelIds}
|
||||
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
|
||||
placeholder="e.g., model-a, model-b (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={updating}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -33,7 +34,6 @@ import JsonView from "react18-json-view";
|
||||
import "react18-json-view/src/style.css";
|
||||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||
|
||||
type FactType = "world" | "experience" | "observation";
|
||||
type Budget = "low" | "mid" | "high";
|
||||
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
|
||||
type ViewMode = "results" | "trace" | "json";
|
||||
@@ -157,10 +157,6 @@ export function SearchDebugView() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFactType = (ft: FactType) => {
|
||||
setFactTypes((prev) => (prev.includes(ft) ? prev.filter((t) => t !== ft) : [...prev, ft]));
|
||||
};
|
||||
|
||||
if (!currentBank) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
@@ -197,28 +193,7 @@ export function SearchDebugView() {
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
|
||||
{/* Fact Types */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Types:</span>
|
||||
<div className="flex gap-3">
|
||||
{(["world", "experience"] as FactType[]).map((ft) => (
|
||||
<label key={ft} className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={factTypes.includes(ft)}
|
||||
onCheckedChange={() => toggleFactType(ft)}
|
||||
/>
|
||||
<span className="text-sm capitalize">{ft}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={factTypes.includes("observation")}
|
||||
onCheckedChange={() => toggleFactType("observation")}
|
||||
/>
|
||||
<span className="text-sm">Observations</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<FactTypeFilter value={factTypes} onChange={setFactTypes} label="Types:" />
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Sparkles,
|
||||
@@ -51,6 +52,9 @@ export function ThinkView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tags, setTags] = useState("");
|
||||
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
|
||||
const [factTypes, setFactTypes] = useState<FactType[]>([]);
|
||||
const [excludeMentalModels, setExcludeMentalModels] = useState(false);
|
||||
const [excludeMentalModelIds, setExcludeMentalModelIds] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
|
||||
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
|
||||
@@ -151,6 +155,11 @@ export function ThinkView() {
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
const excludeIds = excludeMentalModelIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const data: any = await client.reflect({
|
||||
bank_id: currentBank,
|
||||
query,
|
||||
@@ -159,6 +168,9 @@ export function ThinkView() {
|
||||
include_facts: includeFacts,
|
||||
include_tool_calls: includeToolCalls,
|
||||
...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }),
|
||||
...(factTypes.length > 0 && { fact_types: factTypes }),
|
||||
...(excludeMentalModels && { exclude_mental_models: true }),
|
||||
...(excludeIds.length > 0 && { exclude_mental_model_ids: excludeIds }),
|
||||
});
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
@@ -275,6 +287,29 @@ export function ThinkView() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Fact Types & Mental Model Filters */}
|
||||
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
|
||||
<FactTypeFilter value={factTypes} onChange={setFactTypes} />
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={excludeMentalModels}
|
||||
onCheckedChange={(c) => setExcludeMentalModels(c as boolean)}
|
||||
/>
|
||||
<span className="text-sm">Exclude mental models</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Exclude IDs:</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={excludeMentalModelIds}
|
||||
onChange={(e) => setExcludeMentalModelIds(e.target.value)}
|
||||
placeholder="model-a, model-b"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -47,7 +47,12 @@ export interface MentalModel {
|
||||
content: string;
|
||||
tags: string[];
|
||||
max_tokens: number;
|
||||
trigger: { refresh_after_consolidation: boolean };
|
||||
trigger: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
last_refreshed_at: string;
|
||||
created_at: string;
|
||||
reflect_response?: any;
|
||||
@@ -183,6 +188,9 @@ export class ControlPlaneClient {
|
||||
include_tool_calls?: boolean;
|
||||
tags?: string[];
|
||||
tags_match?: "any" | "all" | "any_strict" | "all_strict";
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
}) {
|
||||
return this.fetchApi("/api/reflect", {
|
||||
method: "POST",
|
||||
@@ -757,7 +765,12 @@ export class ControlPlaneClient {
|
||||
content: string;
|
||||
tags: string[];
|
||||
max_tokens: number;
|
||||
trigger: { refresh_after_consolidation: boolean };
|
||||
trigger: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
last_refreshed_at: string;
|
||||
created_at: string;
|
||||
reflect_response?: {
|
||||
@@ -780,7 +793,12 @@ export class ControlPlaneClient {
|
||||
source_query: string;
|
||||
tags?: string[];
|
||||
max_tokens?: number;
|
||||
trigger?: { refresh_after_consolidation: boolean };
|
||||
trigger?: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
}
|
||||
) {
|
||||
return this.fetchApi<{
|
||||
@@ -809,7 +827,12 @@ export class ControlPlaneClient {
|
||||
source_query?: string;
|
||||
max_tokens?: number;
|
||||
tags?: string[];
|
||||
trigger?: { refresh_after_consolidation: boolean };
|
||||
trigger?: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
}
|
||||
) {
|
||||
return this.fetchApi<{
|
||||
@@ -820,7 +843,12 @@ export class ControlPlaneClient {
|
||||
content: string;
|
||||
tags: string[];
|
||||
max_tokens: number;
|
||||
trigger: { refresh_after_consolidation: boolean };
|
||||
trigger: {
|
||||
refresh_after_consolidation: boolean;
|
||||
fact_types?: Array<"world" | "experience" | "observation">;
|
||||
exclude_mental_models?: boolean;
|
||||
exclude_mental_model_ids?: string[];
|
||||
};
|
||||
last_refreshed_at: string;
|
||||
created_at: string;
|
||||
reflect_response?: {
|
||||
|
||||
@@ -25,7 +25,10 @@ GITHUB_REPO = "vectorize-io/hindsight"
|
||||
GITHUB_RELEASES_URL = f"https://github.com/{GITHUB_REPO}/releases"
|
||||
GITHUB_COMMIT_URL = f"https://github.com/{GITHUB_REPO}/commit"
|
||||
REPO_PATH = Path(__file__).parent.parent.parent
|
||||
CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog.md"
|
||||
CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog" / "index.md"
|
||||
INTEGRATION_CHANGELOG_DIR = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog" / "integrations"
|
||||
|
||||
VALID_INTEGRATIONS = ["litellm", "pydantic-ai", "crewai", "ai-sdk", "chat", "openclaw", "langgraph", "nemoclaw"]
|
||||
|
||||
|
||||
class ChangelogEntry(BaseModel):
|
||||
@@ -82,6 +85,31 @@ def get_git_tags() -> list[str]:
|
||||
return valid_tags
|
||||
|
||||
|
||||
def get_integration_tags(integration: str) -> list[str]:
|
||||
"""Get all tags for a specific integration, sorted by semver (newest first)."""
|
||||
prefix = f"integrations/{integration}/v"
|
||||
result = subprocess.run(
|
||||
["git", "tag", "-l", f"{prefix}*"],
|
||||
cwd=REPO_PATH,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||
|
||||
valid_tags = []
|
||||
for tag in tags:
|
||||
version_part = tag.removeprefix(prefix)
|
||||
try:
|
||||
parse_semver(version_part)
|
||||
valid_tags.append(tag)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
valid_tags.sort(key=lambda t: parse_semver(t.removeprefix(prefix)), reverse=True)
|
||||
return valid_tags
|
||||
|
||||
|
||||
def find_previous_version(new_version: str, existing_tags: list[str]) -> str | None:
|
||||
"""Find the previous version based on semver rules."""
|
||||
new_major, new_minor, new_patch = parse_semver(new_version)
|
||||
@@ -105,13 +133,41 @@ def find_previous_version(new_version: str, existing_tags: list[str]) -> str | N
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]:
|
||||
def find_previous_integration_tag(new_version: str, existing_tags: list[str], integration: str) -> str | None:
|
||||
"""Find the previous integration tag based on semver rules."""
|
||||
prefix = f"integrations/{integration}/v"
|
||||
new_major, new_minor, new_patch = parse_semver(new_version)
|
||||
|
||||
candidates = []
|
||||
for tag in existing_tags:
|
||||
version_part = tag.removeprefix(prefix)
|
||||
try:
|
||||
major, minor, patch = parse_semver(version_part)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if (major, minor, patch) >= (new_major, new_minor, new_patch):
|
||||
continue
|
||||
|
||||
candidates.append((tag, (major, minor, patch)))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
def get_commits(from_ref: str | None, to_ref: str, path_filter: str | None = None) -> list[Commit]:
|
||||
"""Get commits between two refs as structured data."""
|
||||
if from_ref:
|
||||
cmd = ["git", "log", "--format=%h|%s", "--no-merges", f"{from_ref}..{to_ref}"]
|
||||
else:
|
||||
cmd = ["git", "log", "--format=%h|%s", "--no-merges", to_ref]
|
||||
|
||||
if path_filter:
|
||||
cmd += ["--", path_filter]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=REPO_PATH,
|
||||
@@ -131,13 +187,16 @@ def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]:
|
||||
return commits
|
||||
|
||||
|
||||
def get_detailed_diff(from_ref: str | None, to_ref: str) -> str:
|
||||
def get_detailed_diff(from_ref: str | None, to_ref: str, path_filter: str | None = None) -> str:
|
||||
"""Get file change stats between two refs."""
|
||||
if from_ref:
|
||||
cmd = ["git", "diff", "--stat", f"{from_ref}..{to_ref}"]
|
||||
else:
|
||||
cmd = ["git", "diff", "--stat", f"{to_ref}^..{to_ref}"]
|
||||
|
||||
if path_filter:
|
||||
cmd += ["--", path_filter]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=REPO_PATH,
|
||||
@@ -153,11 +212,14 @@ def analyze_commits_with_llm(
|
||||
version: str,
|
||||
commits: list[Commit],
|
||||
file_diff: str,
|
||||
integration: str | None = None,
|
||||
) -> list[ChangelogEntry]:
|
||||
"""Use LLM to analyze commits and return structured changelog entries."""
|
||||
commits_json = json.dumps([{"commit_id": c.hash, "message": c.message} for c in commits], indent=2)
|
||||
|
||||
prompt = f"""Analyze the following git commits for release {version} of Hindsight (an AI memory system).
|
||||
subject = f"the {integration} integration for Hindsight" if integration else f"release {version} of Hindsight"
|
||||
|
||||
prompt = f"""Analyze the following git commits for {subject} (an AI memory system).
|
||||
|
||||
For each meaningful change, create a changelog entry with:
|
||||
- category: one of "feature", "improvement", "bugfix", "breaking", "other"
|
||||
@@ -192,9 +254,14 @@ def build_changelog_markdown(
|
||||
version: str,
|
||||
tag: str,
|
||||
entries: list[ChangelogEntry],
|
||||
integration: str | None = None,
|
||||
) -> str:
|
||||
"""Build markdown changelog from structured entries."""
|
||||
release_url = f"{GITHUB_RELEASES_URL}/tag/{tag}"
|
||||
tag_url = (
|
||||
f"https://github.com/{GITHUB_REPO}/releases/tag/{tag}"
|
||||
if not integration
|
||||
else f"https://github.com/{GITHUB_REPO}/tree/{tag}"
|
||||
)
|
||||
|
||||
# Group entries by category
|
||||
categories = {
|
||||
@@ -213,7 +280,7 @@ def build_changelog_markdown(
|
||||
categories["other"][1].append(entry)
|
||||
|
||||
# Build markdown
|
||||
lines = [f"## [{version}]({release_url})", ""]
|
||||
lines = [f"## [{version}]({tag_url})", ""]
|
||||
|
||||
has_entries = False
|
||||
for cat_key in ["breaking", "feature", "improvement", "bugfix", "other"]:
|
||||
@@ -234,22 +301,12 @@ def build_changelog_markdown(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_existing_changelog() -> tuple[str, str]:
|
||||
def read_existing_changelog(path: Path, default_header: str) -> tuple[str, str]:
|
||||
"""Read existing changelog and split into header and content."""
|
||||
if not CHANGELOG_PATH.exists():
|
||||
header = """---
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
if not path.exists():
|
||||
return default_header, ""
|
||||
|
||||
# Changelog
|
||||
|
||||
This changelog highlights user-facing changes only. Internal maintenance, CI/CD, and infrastructure updates are omitted.
|
||||
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
"""
|
||||
return header, ""
|
||||
|
||||
content = CHANGELOG_PATH.read_text()
|
||||
content = path.read_text()
|
||||
|
||||
match = re.search(r"^## ", content, re.MULTILINE)
|
||||
if match:
|
||||
@@ -262,11 +319,11 @@ For full release details, see [GitHub Releases](https://github.com/vectorize-io/
|
||||
return header, releases
|
||||
|
||||
|
||||
def write_changelog(header: str, new_entry: str, existing_releases: str) -> None:
|
||||
def write_changelog(path: Path, header: str, new_entry: str, existing_releases: str) -> None:
|
||||
"""Write changelog with new entry prepended."""
|
||||
content = header + new_entry + "\n" + existing_releases
|
||||
CHANGELOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CHANGELOG_PATH.write_text(content.rstrip() + "\n")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content.rstrip() + "\n")
|
||||
|
||||
|
||||
def generate_changelog_entry(
|
||||
@@ -329,22 +386,154 @@ def generate_changelog_entry(
|
||||
|
||||
new_entry = build_changelog_markdown(display_version, tag, entries)
|
||||
|
||||
header, existing_releases = read_existing_changelog()
|
||||
default_header = """---
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
This changelog highlights user-facing changes only. Internal maintenance, CI/CD, and infrastructure updates are omitted.
|
||||
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
|
||||
"""
|
||||
header, existing_releases = read_existing_changelog(CHANGELOG_PATH, default_header)
|
||||
|
||||
if f"## [{display_version}]" in existing_releases:
|
||||
console.print(f"[red]Error: Version {display_version} already exists in changelog[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
write_changelog(header, new_entry, existing_releases)
|
||||
write_changelog(CHANGELOG_PATH, header, new_entry, existing_releases)
|
||||
|
||||
console.print(f"\n[green]Changelog updated: {CHANGELOG_PATH}[/green]")
|
||||
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
|
||||
|
||||
|
||||
def generate_integration_changelog_entry(
|
||||
integration: str,
|
||||
version: str,
|
||||
llm_model: str = "gpt-5.2",
|
||||
) -> None:
|
||||
"""Generate changelog entry for a specific integration version."""
|
||||
if integration not in VALID_INTEGRATIONS:
|
||||
console.print(f"[red]Error: Unknown integration '{integration}'. Valid: {', '.join(VALID_INTEGRATIONS)}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
console.print("[red]Error: OPENAI_API_KEY environment variable not set[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
display_version = version.lstrip("v")
|
||||
path_filter = f"hindsight-integrations/{integration}/"
|
||||
changelog_path = INTEGRATION_CHANGELOG_DIR / f"{integration}.md"
|
||||
|
||||
console.print(f"[blue]Fetching integration tags for {integration}...[/blue]")
|
||||
existing_tags = get_integration_tags(integration)
|
||||
|
||||
previous_tag = find_previous_integration_tag(display_version, existing_tags, integration)
|
||||
|
||||
if previous_tag:
|
||||
console.print(f"[green]Found previous tag: {previous_tag}[/green]")
|
||||
else:
|
||||
console.print("[yellow]No previous tag found, will include all commits touching this integration[/yellow]")
|
||||
|
||||
console.print(f"[blue]Getting commits for {path_filter}...[/blue]")
|
||||
commits = get_commits(previous_tag, "HEAD", path_filter=path_filter)
|
||||
file_diff = get_detailed_diff(previous_tag, "HEAD", path_filter=path_filter)
|
||||
|
||||
if not commits:
|
||||
console.print("[yellow]Warning: No commits found touching this integration path[/yellow]")
|
||||
entries = []
|
||||
else:
|
||||
console.print(f"[blue]Found {len(commits)} commits[/blue]")
|
||||
|
||||
console.print("\n[bold]Commits:[/bold]")
|
||||
for c in commits:
|
||||
console.print(f" {c.hash} {c.message}")
|
||||
|
||||
console.print("\n[bold]Files changed:[/bold]")
|
||||
console.print(file_diff[:4000] if len(file_diff) > 4000 else file_diff)
|
||||
console.print("")
|
||||
|
||||
console.print(f"[blue]Analyzing commits with LLM ({llm_model})...[/blue]")
|
||||
entries = analyze_commits_with_llm(
|
||||
client, llm_model, display_version, commits, file_diff, integration=integration
|
||||
)
|
||||
|
||||
console.print(f"\n[bold]LLM identified {len(entries)} changelog entries:[/bold]")
|
||||
for entry in entries:
|
||||
console.print(f" [{entry.category}] {entry.summary} ({entry.commit_id})")
|
||||
|
||||
integration_tag = f"integrations/{integration}/v{display_version}"
|
||||
new_entry = build_changelog_markdown(display_version, integration_tag, entries, integration=integration)
|
||||
|
||||
package_name = _get_package_name(integration)
|
||||
default_header = f"""---
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||
# {_integration_display_name(integration)} Integration Changelog
|
||||
|
||||
Changelog for [`{package_name}`]({_package_url(integration, package_name)}).
|
||||
|
||||
For the source code, see [`hindsight-integrations/{integration}`](https://github.com/{GITHUB_REPO}/tree/main/hindsight-integrations/{integration}).
|
||||
|
||||
← [Back to main changelog](/changelog)
|
||||
|
||||
"""
|
||||
header, existing_releases = read_existing_changelog(changelog_path, default_header)
|
||||
|
||||
if f"## [{display_version}]" in existing_releases:
|
||||
console.print(f"[red]Error: Version {display_version} already exists in integration changelog[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
write_changelog(changelog_path, header, new_entry, existing_releases)
|
||||
|
||||
console.print(f"\n[green]Integration changelog updated: {changelog_path}[/green]")
|
||||
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
|
||||
|
||||
|
||||
def _get_package_name(integration: str) -> str:
|
||||
packages = {
|
||||
"litellm": "hindsight-litellm",
|
||||
"pydantic-ai": "hindsight-pydantic-ai",
|
||||
"crewai": "hindsight-crewai",
|
||||
"ai-sdk": "@vectorize-io/hindsight-ai-sdk",
|
||||
"chat": "@vectorize-io/hindsight-chat",
|
||||
"openclaw": "@vectorize-io/hindsight-openclaw",
|
||||
"langgraph": "hindsight-langgraph",
|
||||
"nemoclaw": "@vectorize-io/hindsight-nemoclaw",
|
||||
}
|
||||
return packages[integration]
|
||||
|
||||
|
||||
def _package_url(integration: str, package_name: str) -> str:
|
||||
if package_name.startswith("@"):
|
||||
return f"https://www.npmjs.com/package/{package_name}"
|
||||
return f"https://pypi.org/project/{package_name}/"
|
||||
|
||||
|
||||
def _integration_display_name(integration: str) -> str:
|
||||
names = {
|
||||
"litellm": "LiteLLM",
|
||||
"pydantic-ai": "Pydantic AI",
|
||||
"crewai": "CrewAI",
|
||||
"ai-sdk": "AI SDK",
|
||||
"chat": "Chat SDK",
|
||||
"openclaw": "OpenClaw",
|
||||
"langgraph": "LangGraph",
|
||||
"nemoclaw": "NemoClaw",
|
||||
}
|
||||
return names.get(integration, integration)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate changelog entry for a release",
|
||||
usage="generate-changelog VERSION [--model MODEL]",
|
||||
usage="generate-changelog VERSION [--model MODEL] [--integration NAME]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
@@ -355,13 +544,25 @@ def main():
|
||||
default="gpt-5.2",
|
||||
help="OpenAI model to use (default: gpt-5.2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--integration",
|
||||
default=None,
|
||||
help=f"Generate changelog for a specific integration. Valid: {', '.join(VALID_INTEGRATIONS)}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generate_changelog_entry(
|
||||
version=args.version,
|
||||
llm_model=args.model,
|
||||
)
|
||||
if args.integration:
|
||||
generate_integration_changelog_entry(
|
||||
integration=args.integration,
|
||||
version=args.version,
|
||||
llm_model=args.model,
|
||||
)
|
||||
else:
|
||||
generate_changelog_entry(
|
||||
version=args.version,
|
||||
llm_model=args.model,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "Give the Only Self-Improving AI Agent (Hermes) a Memory Upgrade It Deserves"
|
||||
authors: [benfrank241]
|
||||
date: 2026-03-17
|
||||
tags: [hermes, agents, python, memory, tutorial, plugin]
|
||||
image: /img/blog/hermes-agent-memory.png
|
||||
---
|
||||
|
||||

|
||||
|
||||
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a self-improving AI agent with 40+ tools and a plugin system. Its built-in memory saves to local files. `hindsight-hermes` replaces it with structured fact extraction, entity resolution, and multi-strategy retrieval — via one pip install and three environment variables.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
**TL;DR:**
|
||||
- Hermes Agent's built-in memory is local file-based — no structure, no retrieval intelligence, no cross-machine sync
|
||||
- `hindsight-hermes` is a pip-installable plugin that registers Hindsight retain/recall/reflect as native Hermes tools
|
||||
- One `pip install`, three environment variables, disable the built-in `memory` tool, and you're done
|
||||
- Works with [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) (zero infra) or self-hosted
|
||||
|
||||
## The problem: good memory, but it could go further
|
||||
|
||||
Hermes Agent has memory built in, and it's a reasonable design. The `memory` tool saves durable facts to `~/.hermes/` as persistent files, and the `session_search` tool lets the agent look back through past conversations. It works — the agent can store preferences, recall context, and carry knowledge across sessions.
|
||||
|
||||
But there's room to grow:
|
||||
|
||||
- **Structure.** Memories are stored as text. There's no entity resolution (connecting "Alice" with "my coworker Alice from engineering"), no relationship tracking, and no temporal awareness beyond session timestamps.
|
||||
- **Retrieval.** Search is keyword-based. For simple lookups that's fine, but it struggles with questions that use different terminology than what was stored.
|
||||
- **Locality.** Memories live on disk. Run Hermes on your laptop and your server — two separate brains with no way to share context.
|
||||
- **Synthesis.** You can store and retrieve facts, but you can't ask "based on everything you know about this customer, what should we prioritize?" and get a reasoned answer.
|
||||
|
||||
Hindsight adds the layer on top: structured fact extraction, entity resolution, a knowledge graph, multi-strategy retrieval with cross-encoder reranking, and a `reflect` operation that synthesizes across all stored memories.
|
||||
|
||||
## Architecture: a plugin that registers three tools
|
||||
|
||||
```
|
||||
User ──> Hermes Agent
|
||||
├── Built-in tools (terminal, browser, files, ...)
|
||||
└── [hindsight] plugin
|
||||
├── hindsight_retain ──> Hindsight API ──> fact extraction,
|
||||
├── hindsight_recall ──> │ entity resolution,
|
||||
└── hindsight_reflect ──> │ knowledge graph,
|
||||
└──> PostgreSQL + pgvector
|
||||
```
|
||||
|
||||
`hindsight-hermes` hooks into Hermes's [plugin system](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/plugins.py). When Hermes starts, it scans for packages with the `hermes_agent.plugins` entry point, finds `hindsight-hermes`, and calls its `register()` function. That registers three tools into Hermes's tool registry.
|
||||
|
||||
No forking Hermes. No patching config files. Just `pip install` and environment variables.
|
||||
|
||||
## Setting up Hindsight
|
||||
|
||||
You have two options: Hindsight Cloud (no setup) or self-hosted.
|
||||
|
||||
**Option A: Hindsight Cloud**
|
||||
|
||||
1. [Sign up at Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
|
||||
2. Create a memory bank in the dashboard and copy your API key
|
||||
3. Your base URL is `https://api.hindsight.vectorize.io`
|
||||
|
||||
**Option B: Self-hosted with Docker**
|
||||
|
||||
```bash
|
||||
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=YOUR_OPENAI_KEY \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
Wait for the health check:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8888/health
|
||||
# {"status":"healthy","database":"connected"}
|
||||
```
|
||||
|
||||
The `-v` flag persists data across container restarts. Port 8888 is the API; port 9999 is the admin UI for browsing memories.
|
||||
|
||||
**Option C: Self-hosted with pip**
|
||||
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
export HINDSIGHT_API_LLM_API_KEY=YOUR_OPENAI_KEY
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
## Install hindsight-hermes
|
||||
|
||||
One pip install. The package auto-registers as a Hermes plugin via Python entry points — no config files, no manual plugin setup. The only requirement is that it's installed in the **same Python environment** as Hermes.
|
||||
|
||||
```bash
|
||||
uv pip install hindsight-hermes --python $HOME/.hermes/hermes-agent/venv/bin/python
|
||||
```
|
||||
|
||||
That's it. When Hermes starts, it discovers the package automatically and registers the three memory tools.
|
||||
|
||||
You can verify it's registered:
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import importlib.metadata
|
||||
eps = importlib.metadata.entry_points(group='hermes_agent.plugins')
|
||||
for ep in eps:
|
||||
print(f'{ep.name}: {ep.value}')
|
||||
"
|
||||
# Expected: hindsight: hindsight_hermes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set these environment variables before launching Hermes:
|
||||
|
||||
```bash
|
||||
# Required — where Hindsight is running
|
||||
export HINDSIGHT_API_URL=http://localhost:8888
|
||||
|
||||
# Required — the memory bank (an isolated "brain" for this agent)
|
||||
export HINDSIGHT_BANK_ID=my-agent
|
||||
|
||||
# Optional — only needed for Hindsight Cloud (https://api.hindsight.vectorize.io)
|
||||
export HINDSIGHT_API_KEY=hsk_your-key-here
|
||||
|
||||
# Optional — recall budget: low (fast), mid (default), high (thorough)
|
||||
export HINDSIGHT_BUDGET=mid
|
||||
```
|
||||
|
||||
If neither `HINDSIGHT_API_URL` nor `HINDSIGHT_API_KEY` is set, the plugin silently skips registration — Hermes starts normally without the Hindsight tools.
|
||||
|
||||
### Disable Hermes's built-in memory
|
||||
|
||||
This is the step people miss. Hermes has its own `memory` tool that saves to `~/.hermes/`. If both are active, **the LLM defaults to the built-in one** — it's a single tool it already recognizes. Your Hindsight tools will sit unused.
|
||||
|
||||
```bash
|
||||
hermes tools disable memory
|
||||
```
|
||||
|
||||
This persists across sessions. Re-enable later with `hermes tools enable memory`.
|
||||
|
||||
## Using the memory tools
|
||||
|
||||
Launch Hermes:
|
||||
|
||||
```bash
|
||||
hermes
|
||||
```
|
||||
|
||||
Type `/tools` to verify. You should see the `[hindsight]` toolset:
|
||||
|
||||
```
|
||||
[hindsight]
|
||||
* hindsight_recall - Search long-term memory for relevant information.
|
||||
* hindsight_reflect - Synthesize a thoughtful answer from long-term memories.
|
||||
* hindsight_retain - Store information to long-term memory for later retrieval.
|
||||
```
|
||||
|
||||
### Retain — store memories
|
||||
|
||||
Tell Hermes something to remember:
|
||||
|
||||
```
|
||||
● Remember that my favourite programming language is Rust and I prefer dark mode.
|
||||
```
|
||||
|
||||
You should see `⚡ hindsight` in the response — that confirms it called `hindsight_retain`, not the built-in memory tool.
|
||||
|
||||
Under the hood, Hindsight extracts structured facts ("User's favourite programming language is Rust"), resolves entities, generates embeddings, and indexes everything. You don't manage any of that.
|
||||
|
||||
### Recall — search memories
|
||||
|
||||
```
|
||||
● What do you know about my programming preferences?
|
||||
```
|
||||
|
||||
Recall runs four retrieval strategies in parallel — semantic search, BM25 keyword matching, entity graph traversal, and temporal filtering — then reranks results with a cross-encoder. This is what makes it work better than string matching over flat files.
|
||||
|
||||
### Reflect — synthesize across memories
|
||||
|
||||
```
|
||||
● Based on what you know about me, suggest a colour scheme for my IDE.
|
||||
```
|
||||
|
||||
Reflect doesn't return raw facts. It traverses the knowledge graph, reasons across everything in the bank, and produces a synthesized answer. Slower than recall, but far more useful for open-ended questions.
|
||||
|
||||
### Verify via the API
|
||||
|
||||
Confirm memories are stored by querying Hindsight directly:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8888/v1/default/banks/my-agent/memories/recall \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "programming preferences", "budget": "low"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"text": "User's favourite programming language is Rust.",
|
||||
"type": "world",
|
||||
"entities": ["user"]
|
||||
},
|
||||
{
|
||||
"text": "User prefers dark mode in all editors.",
|
||||
"type": "world",
|
||||
"entities": ["user"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Pitfalls and edge cases
|
||||
|
||||
1. **Plugin not in `/tools`.** The most common cause: `hindsight-hermes` is installed in a different Python environment than Hermes. Entry points are per-environment. Run `python -c "import importlib.metadata; print(list(importlib.metadata.entry_points(group='hermes_agent.plugins')))"` from the Hermes venv to verify.
|
||||
|
||||
2. **LLM picks built-in memory.** Even with the plugin loaded, if both `memory` and `hindsight_retain` exist, the LLM chooses `memory`. Run `hermes tools disable memory`.
|
||||
|
||||
3. **Retain is asynchronous.** The API returns immediately; fact extraction happens in the background. If you retain and immediately recall in the same turn, the new facts may not be indexed yet. Design so recall happens on subsequent turns.
|
||||
|
||||
4. **Env vars are read once at startup.** Changing `HINDSIGHT_API_URL` or `HINDSIGHT_BANK_ID` after launch has no effect. Restart Hermes to pick up changes.
|
||||
|
||||
|
||||
## Tradeoffs: Hindsight plugin vs. alternatives
|
||||
|
||||
| | **Hindsight plugin** | **Built-in memory** |
|
||||
|---|---|---|
|
||||
| **Storage** | PostgreSQL + pgvector | Local files (~/.hermes/) |
|
||||
| **Structure** | Facts, entities, relationships | Raw text |
|
||||
| **Retrieval** | Semantic + BM25 + graph, reranked | Basic search |
|
||||
| **Synthesis** | reflect tool | None |
|
||||
| **Cross-machine** | Yes | No |
|
||||
| **Setup** | pip install + env vars | Built-in |
|
||||
|
||||
**Use the built-in memory** when you want zero setup and basic persistence.
|
||||
|
||||
**Use the Hindsight plugin** when you want structured retrieval, entity resolution, and memory that persists across machines.
|
||||
|
||||
## Recap
|
||||
|
||||
`hindsight-hermes` gives Hermes Agent persistent, structured long-term memory via a pip-installable plugin. No code changes, no config patches.
|
||||
|
||||
Hermes's plugin system uses standard Python entry points, so any pip package can register tools. `hindsight-hermes` injects retain, recall, and reflect — backed by Hindsight's multi-strategy retrieval, entity resolution, and knowledge graph.
|
||||
|
||||
The key practical detail: disable Hermes's built-in `memory` tool. Otherwise the LLM prefers it and your Hindsight tools go unused.
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Build up memory over time.** Use Hermes normally — it will retain what matters and recall it when relevant.
|
||||
- **Try reflect for synthesis.** Ask open-ended questions: "Based on everything you know about me, what kind of projects would I enjoy?"
|
||||
- **Use per-user banks.** Set `HINDSIGHT_BANK_ID` per user for isolated memory per person.
|
||||
- **Explore the MCP alternative.** Hermes supports MCP servers natively. You can connect Hindsight's MCP server directly (`http://localhost:8888/mcp`) instead of the plugin — no `hindsight-hermes` package needed. The tradeoff is that the plugin registers tools with Hermes-native schemas, while MCP tools need discovery.
|
||||
- **Use manual registration for more control.** If you want to set tags, recall filters, or skip the plugin system, `hindsight-hermes` exposes `register_tools()` and `memory_instructions()` functions for direct use.
|
||||
- **Read the docs.** Full integration reference at [hindsight.vectorize.io/sdks/integrations/hermes](https://hindsight.vectorize.io/sdks/integrations/hermes).
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
slug: sandboxed-agent-persistent-memory-nemoclaw
|
||||
title: "Give NemoClaw the Best Agent Memory Available In One Command"
|
||||
description: Add persistent memory to a NemoClaw sandboxed AI agent without changing code. One command, one network policy, memories survive across sessions.
|
||||
authors: [hindsight]
|
||||
date: 2026-03-19
|
||||
image: /img/blog/2026-03-19/nemoclaw-memory.png
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- [NemoClaw](https://nemoclaw.ai) sandboxes isolate AI agents — controlled filesystem, processes, and network. That isolation makes persistent memory harder.
|
||||
- We connected the `hindsight-openclaw` plugin to a live NemoClaw sandbox using [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup). No code changes — one command.
|
||||
- External API mode is the natural fit: the plugin becomes a thin HTTP client, and the sandbox only needs one egress rule.
|
||||
- Memories captured in one session are recalled in the next. The sandbox didn't interfere.
|
||||
- The pattern generalizes: sandbox controls what the agent can *do*, memory controls what it *knows*. They compose cleanly.
|
||||
|
||||
## The Problem: Sandboxed Agents Have No Persistent Memory
|
||||
|
||||
AI agents running inside sandboxes present an interesting memory problem. The sandbox is designed to isolate the agent — it controls which files it can read, which processes it can spawn, and which network endpoints it can reach. That isolation is the point. But it creates a question: if every session starts in a clean, constrained environment, where does persistent memory live?
|
||||
|
||||
We set out to answer that with [NemoClaw](https://nemoclaw.ai), NVIDIA's sandboxed agent runtime built on OpenShell. The goal was simple: connect the `hindsight-openclaw` plugin to a live NemoClaw sandbox and verify that memories captured in one session are recalled in the next. No code changes allowed — if we needed to modify the plugin to make it work, we'd learned something important about the architecture.
|
||||
|
||||
We didn't need to change a line.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## The Approach: External API Mode for Sandbox Memory
|
||||
|
||||
[NemoClaw](https://nemoclaw.ai) runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox. The sandbox enforces a filesystem policy (what paths the agent can read and write), a process policy (what it runs as), and a network egress policy (which outbound endpoints are permitted).
|
||||
|
||||
By default, the sandbox ships with policies for the services it needs: the LLM provider, GitHub, npm, the OpenClaw API. Everything else is blocked. That's a good default — an agent that can call arbitrary endpoints is harder to trust.
|
||||
|
||||
[Hindsight](https://hindsight.vectorize.io) operates as an external API. The plugin makes HTTPS calls to `api.hindsight.vectorize.io` to [retain and recall memories](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). From the sandbox's perspective, that's just another outbound endpoint — one that needs to be explicitly permitted.
|
||||
|
||||
The full stack looks like this:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ NemoClaw Sandbox (OpenShell) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ OpenClaw Gateway │ │
|
||||
│ │ + hindsight-openclaw plugin │ │
|
||||
│ │ ↓ before_agent_start: recall │ │
|
||||
│ │ ↓ agent_end: retain │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Network egress policy: │
|
||||
│ ✓ api.anthropic.com │
|
||||
│ ✓ integrate.api.nvidia.com │
|
||||
│ ✓ api.hindsight.vectorize.io ← added │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When the plugin retains a conversation, Hindsight doesn't just store raw text. It extracts structured facts, resolves entities, builds a [knowledge graph](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs), and indexes everything for multi-strategy retrieval — semantic search, BM25 keyword matching, graph traversal, and temporal filtering with [cross-encoder reranking](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). That's what makes recall useful even when the agent's question doesn't match the exact wording of what was stored.
|
||||
|
||||
The plugin has two modes. In **local daemon mode**, it spawns a local `hindsight-embed` process and communicates with it over a local port. In **external API mode**, it skips the daemon entirely and makes HTTP calls directly to a Hindsight Cloud endpoint.
|
||||
|
||||
Inside a sandbox, local daemon mode is awkward. The sandbox controls which processes can be spawned, and a background daemon that launches `uvx` subprocesses is friction we don't need. External API mode is the natural fit: the plugin becomes a thin HTTP client, and the only infrastructure requirement is a network egress rule.
|
||||
|
||||
For background on the OpenClaw plugin itself — how it hooks into the gateway lifecycle, auto-injects memory into context, and prevents feedback loops — see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
|
||||
|
||||
## Implementation: One Command
|
||||
|
||||
The `hindsight-nemoclaw` package automates the entire setup — installing the plugin, configuring external API mode, reading your current sandbox policy, merging the Hindsight egress rule, and restarting the gateway:
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-nemoclaw setup \
|
||||
--sandbox my-assistant \
|
||||
--api-url https://api.hindsight.vectorize.io \
|
||||
--api-token <your-api-key> \
|
||||
--bank-prefix my-sandbox
|
||||
```
|
||||
|
||||
That's it. You'll see output like:
|
||||
|
||||
```
|
||||
[0] Preflight checks...
|
||||
✓ openshell found
|
||||
✓ openclaw found
|
||||
|
||||
[1] Installing @vectorize-io/hindsight-openclaw plugin...
|
||||
✓ Plugin installed
|
||||
|
||||
[2] Configuring plugin in ~/.openclaw/openclaw.json...
|
||||
✓ Plugin config written (bank: my-sandbox-openclaw)
|
||||
|
||||
[3] Applying Hindsight network policy to sandbox "my-assistant"...
|
||||
✓ Policy version 2 submitted
|
||||
✓ Policy version 2 loaded (active version: 2)
|
||||
|
||||
[4] Restarting OpenClaw gateway...
|
||||
✓ Gateway restarted
|
||||
|
||||
✓ Setup complete!
|
||||
```
|
||||
|
||||
Use `--dry-run` to preview all changes before applying. Use `--skip-policy` if you manage sandbox policies manually.
|
||||
|
||||
## Verifying It Works
|
||||
|
||||
After setup, the gateway logs confirm the plugin is running:
|
||||
|
||||
```
|
||||
[Hindsight] Plugin loaded successfully
|
||||
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
|
||||
[Hindsight] External API health: {"status":"healthy","database":"connected"}
|
||||
[Hindsight] Default bank: my-sandbox-openclaw
|
||||
[Hindsight] ✓ Ready (external API mode)
|
||||
```
|
||||
|
||||
Send a message to the agent:
|
||||
|
||||
```bash
|
||||
openclaw agent --agent main --session-id session-1 \
|
||||
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
|
||||
```
|
||||
|
||||
The gateway logs show the hooks firing:
|
||||
|
||||
```
|
||||
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
|
||||
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
|
||||
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
|
||||
```
|
||||
|
||||
Open a fresh session and ask what the agent remembers:
|
||||
|
||||
```bash
|
||||
openclaw agent --agent main --session-id session-2 \
|
||||
-m "What do you remember about me?"
|
||||
```
|
||||
|
||||
```
|
||||
Right now I've just got the basics: your name is Ben, you're working on
|
||||
Hindsight, and you like commit messages to be detailed. If there's anything
|
||||
else you want me to keep in mind, let me know.
|
||||
```
|
||||
|
||||
The memory survived the session boundary. The sandbox didn't interfere with it.
|
||||
|
||||
## What the Setup Command Does (Manual Alternative)
|
||||
|
||||
If you prefer to apply the steps yourself, here's what `hindsight-nemoclaw setup` does under the hood.
|
||||
|
||||
**Install the plugin:**
|
||||
|
||||
```bash
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
**Configure `~/.openclaw/openclaw.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
|
||||
"hindsightApiToken": "<your-api-key>",
|
||||
"llmProvider": "claude-code",
|
||||
"dynamicBankId": false,
|
||||
"bankIdPrefix": "my-sandbox"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add the Hindsight block to your sandbox network policy** (note: `openshell policy set` replaces the full document — include all existing policies):
|
||||
|
||||
```yaml
|
||||
network_policies:
|
||||
hindsight:
|
||||
name: hindsight
|
||||
endpoints:
|
||||
- host: api.hindsight.vectorize.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: /**
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
- allow:
|
||||
method: PUT
|
||||
path: /**
|
||||
binaries:
|
||||
- path: /usr/local/bin/openclaw
|
||||
```
|
||||
|
||||
```bash
|
||||
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Pitfalls & Edge Cases
|
||||
|
||||
### 1. Policy replacement is full-document
|
||||
|
||||
`openshell policy set` replaces the entire policy document, not just the section you're adding. The `hindsight-nemoclaw setup` command handles this automatically — it reads the current policy, merges the Hindsight block, and re-applies the full document. If you're applying manually, make sure your YAML includes all existing network policies.
|
||||
|
||||
### 2. LaunchAgent can't follow symlinks on macOS
|
||||
|
||||
On macOS, the OpenClaw gateway runs as a LaunchAgent with a restricted security context that can't access `~/Documents` or other user directories. `openclaw plugins install --link` creates a symlink that the LaunchAgent can't follow — install as a copy instead:
|
||||
|
||||
```bash
|
||||
# This works — copies files to ~/.openclaw/extensions/
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
If you see `EPERM: operation not permitted, scandir` in your gateway logs, this is what's happening.
|
||||
|
||||
### 3. Memory retention is asynchronous
|
||||
|
||||
When the plugin calls `retain` at the end of a session, [fact extraction and entity resolution](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs) happen in the background on Hindsight's side. If you open a new session immediately, the most recent memories may not be indexed yet. In practice this is a few seconds — but it's worth knowing if you're testing back-to-back.
|
||||
|
||||
### 4. Binary-scoped egress is strict
|
||||
|
||||
The `binaries` field in the network policy means *only* the specified executable can reach the endpoint. If you update OpenClaw and the binary path changes, the egress rule silently stops working. Check your binary path after upgrades.
|
||||
|
||||
## Tradeoffs: External API vs. Local Daemon in a Sandbox
|
||||
|
||||
| | **External API mode** | **Local daemon mode** |
|
||||
|---|---|---|
|
||||
| **Setup** | One command | Process spawning permissions |
|
||||
| **Dependencies** | HTTPS egress only | `uvx`, Python, local PostgreSQL |
|
||||
| **Data location** | Hindsight Cloud | Local to sandbox |
|
||||
| **Multi-sandbox sharing** | Same bank from anywhere | Per-sandbox only |
|
||||
| **Sandbox compatibility** | Clean fit | Fights the process policy |
|
||||
|
||||
**Use external API mode** when you're in a sandbox, want shared memory across instances, or don't want to manage a local database.
|
||||
|
||||
**Use local daemon mode** when data must stay on the machine, network egress is completely locked down, or you're running outside a sandbox where process spawning is unrestricted.
|
||||
|
||||
For background on the local daemon approach, see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
|
||||
|
||||
## What This Pattern Means for Sandboxed Agent Memory
|
||||
|
||||
The pattern here is worth naming. A sandboxed agent isn't a limitation on persistent memory — it's a different trust boundary:
|
||||
|
||||
- **Sandbox** controls what the agent can *do* — filesystem access, process spawning, network calls.
|
||||
- **Memory** controls what the agent *knows* — facts, entities, context from prior sessions.
|
||||
|
||||
Those are orthogonal concerns, and they compose cleanly.
|
||||
|
||||
By keeping memory in an external service and making the network policy explicit, you get both: an agent that's constrained in what it can affect, and one that builds durable knowledge across sessions. The policy file is a readable record of every external dependency the agent has. That transparency is useful.
|
||||
|
||||
There's also an interesting property of `dynamicBankId`:
|
||||
|
||||
- **Enabled** (`true`): each user gets an isolated memory bank. Memories from one user's sessions can't bleed into another's. Use this for multi-tenant deployments.
|
||||
- **Disabled** (`false`): a shared bank accumulates context from all sessions. Use this for single-user sandboxes like a personal coding assistant.
|
||||
|
||||
> **Want to skip self-hosting?** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) is what we used in this walkthrough — no Docker, no infrastructure. Sign up, grab an API key, and run `npx @vectorize-io/hindsight-nemoclaw setup`.
|
||||
|
||||
## Recap
|
||||
|
||||
Persistent memory in a sandboxed AI agent is one command: `npx @vectorize-io/hindsight-nemoclaw setup`. It installs the plugin, applies the network egress rule, and configures external API mode — everything the sandbox needs to let Hindsight through.
|
||||
|
||||
The key insight: sandbox isolation and persistent memory are orthogonal concerns. The sandbox controls what the agent can affect; memory controls what the agent knows. One network policy rule bridges them without compromising either.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Run the setup**: `npx @vectorize-io/hindsight-nemoclaw setup --help` to get started.
|
||||
- **Try per-user memory banks**: Enable `dynamicBankId: true` to give each user isolated memory in multi-tenant deployments.
|
||||
- **Explore the OpenClaw plugin in depth**: See [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight) for how the plugin hooks into gateway lifecycle events.
|
||||
- **Connect other agents to the same memory**: Hindsight works with [Hermes Agent](https://hindsight.vectorize.io/blog/2026/03/17/hermes-agent-memory), [Streamlit chatbots](https://hindsight.vectorize.io/blog/2026/03/17/python-chatbot-memory-streamlit), and [any MCP client](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory).
|
||||
- **Check out the docs**: Full API reference and SDK guides at [docs.hindsight.vectorize.io](https://docs.hindsight.vectorize.io/recall/).
|
||||
|
||||
---
|
||||
|
||||
**Resources:**
|
||||
- [hindsight-nemoclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-nemoclaw)
|
||||
- [hindsight-openclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-openclaw)
|
||||
- [OpenClaw plugin documentation](https://vectorize.io/hindsight/sdks/integrations/openclaw)
|
||||
- [Hindsight Cloud](https://ui.hindsight.vectorize.io)
|
||||
@@ -13,6 +13,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
{/* Import raw source files */}
|
||||
import documentsPy from '!!raw-loader!@site/examples/api/documents.py';
|
||||
import documentsMjs from '!!raw-loader!@site/examples/api/documents.mjs';
|
||||
import documentsGo from '!!raw-loader!@site/examples/api/documents.go';
|
||||
|
||||
:::tip Prerequisites
|
||||
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
|
||||
@@ -60,6 +61,9 @@ hindsight memory retain my-bank "Meeting notes content..." --doc-id notes-2024-0
|
||||
hindsight memory retain-files my-bank docs/
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-retain" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -84,6 +88,9 @@ hindsight memory retain my-bank "Project deadline: March 31" --doc-id project-pl
|
||||
hindsight memory retain my-bank "Project deadline: April 15 (extended)" --doc-id project-plan
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-update" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -104,6 +111,9 @@ Retrieve a document's original text and metadata. This is useful for expanding d
|
||||
hindsight document get my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-get" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -128,6 +138,9 @@ hindsight document update-tags my-bank meeting-2024-03-15 --tags team-a --tags t
|
||||
hindsight document update-tags my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-update" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -152,6 +165,9 @@ Remove a document and all its associated memories:
|
||||
hindsight document delete my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-delete" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -183,6 +199,9 @@ hindsight document list my-bank --q report
|
||||
hindsight document list my-bank --tags team-a --tags team-b
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={documentsGo} section="document-list" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
{/* Import raw source files */}
|
||||
import mainMethodsPy from '!!raw-loader!@site/examples/api/main-methods.py';
|
||||
import mainMethodsMjs from '!!raw-loader!@site/examples/api/main-methods.mjs';
|
||||
import mainMethodsGo from '!!raw-loader!@site/examples/api/main-methods.go';
|
||||
|
||||
:::tip Prerequisites
|
||||
Make sure you've [installed Hindsight](../installation) and completed the [Quick Start](./quickstart).
|
||||
@@ -33,15 +34,18 @@ Store conversations, documents, and facts into a memory bank.
|
||||
|
||||
```bash
|
||||
# Store a single fact
|
||||
hindsight retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
|
||||
hindsight memory retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
|
||||
|
||||
# Store from a file
|
||||
hindsight retain my-bank --file conversation.txt --context "Daily standup"
|
||||
hindsight memory retain-files my-bank conversation.txt --context "Daily standup"
|
||||
|
||||
# Store multiple files
|
||||
hindsight retain my-bank --files docs/*.md
|
||||
hindsight memory retain-files my-bank docs/
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mainMethodsGo} section="main-retain" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -66,18 +70,21 @@ Search for relevant memories using multi-strategy retrieval.
|
||||
|
||||
```bash
|
||||
# Basic search
|
||||
hindsight recall my-bank "What does Alice do at Google?"
|
||||
hindsight memory recall my-bank "What does Alice do at Google?"
|
||||
|
||||
# Search with options
|
||||
hindsight recall my-bank "What happened last spring?" \
|
||||
hindsight memory recall my-bank "What happened last spring?" \
|
||||
--budget high \
|
||||
--max-tokens 8192 \
|
||||
--fact-type world
|
||||
--fact-type world,experience
|
||||
|
||||
# Verbose output (shows weights and sources)
|
||||
hindsight recall my-bank "Tell me about Alice" -v
|
||||
# Verbose output
|
||||
hindsight memory recall my-bank "Tell me about Alice" -v
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mainMethodsGo} section="main-recall" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -102,15 +109,15 @@ Generate disposition-aware responses using memories and observations.
|
||||
|
||||
```bash
|
||||
# Basic reflect
|
||||
hindsight reflect my-bank "Should we adopt TypeScript for our backend?"
|
||||
|
||||
# Verbose output (shows sources and observations)
|
||||
hindsight reflect my-bank "What are Alice's strengths for the team lead role?" -v
|
||||
hindsight memory reflect my-bank "Should we adopt TypeScript for our backend?"
|
||||
|
||||
# With higher reasoning budget
|
||||
hindsight reflect my-bank "Analyze our tech stack" --budget high
|
||||
hindsight memory reflect my-bank "Analyze our tech stack" --budget high
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mainMethodsGo} section="main-reflect" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -13,8 +13,12 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
{/* Import raw source files */}
|
||||
import memoryBanksPy from '!!raw-loader!@site/examples/api/memory-banks.py';
|
||||
import memoryBanksMjs from '!!raw-loader!@site/examples/api/memory-banks.mjs';
|
||||
import memoryBanksSh from '!!raw-loader!@site/examples/api/memory-banks.sh';
|
||||
import memoryBanksGo from '!!raw-loader!@site/examples/api/memory-banks.go';
|
||||
import directivesPy from '!!raw-loader!@site/examples/api/directives.py';
|
||||
import directivesMjs from '!!raw-loader!@site/examples/api/directives.mjs';
|
||||
import directivesSh from '!!raw-loader!@site/examples/api/directives.sh';
|
||||
import directivesGo from '!!raw-loader!@site/examples/api/directives.go';
|
||||
|
||||
## What is a Memory Bank?
|
||||
|
||||
@@ -44,11 +48,10 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
|
||||
<CodeSnippet code={memoryBanksMjs} section="create-bank" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
hindsight bank create my-bank
|
||||
```
|
||||
|
||||
<CodeSnippet code={memoryBanksSh} section="create-bank" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={memoryBanksGo} section="create-bank" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -205,6 +208,12 @@ How skeptical vs trusting the bank is when evaluating claims during `reflect`. S
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={memoryBanksMjs} section="bank-with-disposition" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={memoryBanksSh} section="bank-with-disposition" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={memoryBanksGo} section="bank-with-disposition" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
| Value | Behaviour |
|
||||
@@ -275,6 +284,12 @@ Bank configuration fields (retain mission, extraction mode, observations mission
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={memoryBanksMjs} section="update-bank-config" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={memoryBanksSh} section="update-bank-config" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={memoryBanksGo} section="update-bank-config" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
You can update any subset of fields — only the keys you provide are changed.
|
||||
@@ -288,6 +303,12 @@ You can update any subset of fields — only the keys you provide are changed.
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={memoryBanksMjs} section="get-bank-config" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={memoryBanksSh} section="get-bank-config" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={memoryBanksGo} section="get-bank-config" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The response distinguishes:
|
||||
@@ -303,6 +324,12 @@ The response distinguishes:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={memoryBanksMjs} section="reset-bank-config" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={memoryBanksSh} section="reset-bank-config" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={memoryBanksGo} section="reset-bank-config" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
This removes all bank-level overrides. The bank reverts to server-wide defaults (set via environment variables).
|
||||
@@ -337,6 +364,12 @@ Use directives for rules that must never be violated:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={directivesMjs} section="create-directive" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={directivesSh} section="create-directive" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={directivesGo} section="create-directive" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Listing Directives
|
||||
@@ -348,6 +381,12 @@ Use directives for rules that must never be violated:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={directivesMjs} section="list-directives" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={directivesSh} section="list-directives" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={directivesGo} section="list-directives" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Updating Directives
|
||||
@@ -359,6 +398,12 @@ Use directives for rules that must never be violated:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={directivesMjs} section="update-directive" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={directivesSh} section="update-directive" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={directivesGo} section="update-directive" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Deleting Directives
|
||||
@@ -370,6 +415,12 @@ Use directives for rules that must never be violated:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={directivesMjs} section="delete-directive" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={directivesSh} section="delete-directive" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={directivesGo} section="delete-directive" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Directives vs Disposition
|
||||
|
||||
@@ -12,6 +12,9 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
|
||||
{/* Import raw source files */}
|
||||
import mentalModelsPy from '!!raw-loader!@site/examples/api/mental-models.py';
|
||||
import mentalModelsMjs from '!!raw-loader!@site/examples/api/mental-models.mjs';
|
||||
import mentalModelsSh from '!!raw-loader!@site/examples/api/mental-models.sh';
|
||||
import mentalModelsGo from '!!raw-loader!@site/examples/api/mental-models.go';
|
||||
|
||||
## What Are Mental Models?
|
||||
|
||||
@@ -56,22 +59,14 @@ Creating a mental model runs a reflect operation in the background and saves the
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="create-mental-model" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="create-mental-model" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
# Create a mental model (async operation)
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Team Communication Preferences",
|
||||
"source_query": "How does the team prefer to communicate?",
|
||||
"tags": ["team"]
|
||||
}'
|
||||
|
||||
# Response: {"operation_id": "op-123"}
|
||||
# Use the operations endpoint to check completion
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="create-mental-model" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="create-mental-model" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -81,12 +76,38 @@ curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Human-readable name for the mental model |
|
||||
| `source_query` | string | Yes | The query to run to generate content |
|
||||
| `id` | string | No | Custom ID for the mental model (alphanumeric lowercase with hyphens). Auto-generated if omitted. |
|
||||
| `tags` | list | No | Tags for filtering during retrieval |
|
||||
| `max_tokens` | int | No | Maximum tokens for the mental model content |
|
||||
| `trigger` | object | No | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |
|
||||
|
||||
---
|
||||
|
||||
## Create with Custom ID
|
||||
|
||||
Assign a stable, human-readable ID to a mental model so you can retrieve or update it by name instead of relying on the auto-generated UUID:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-id" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-id" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-id" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-id" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
Custom IDs must be lowercase alphanumeric and may contain hyphens (e.g. `team-policies`, `q4-status`). If a mental model with that ID already exists, the request is rejected.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Automatic Refresh
|
||||
|
||||
Mental models can be configured to **automatically refresh** when observations are updated. This keeps them in sync with the latest knowledge without manual intervention.
|
||||
@@ -103,19 +124,14 @@ When `refresh_after_consolidation` is enabled, the mental model will be re-gener
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-trigger" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-trigger" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
# Create a mental model with automatic refresh enabled
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Project Status",
|
||||
"source_query": "What is the current project status?",
|
||||
"trigger": {"refresh_after_consolidation": true}
|
||||
}'
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-trigger" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-trigger" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -140,12 +156,14 @@ Enable automatic refresh for mental models that need to stay current. Disable it
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="list-mental-models" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="list-mental-models" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="list-mental-models" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="list-mental-models" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -157,12 +175,14 @@ curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="get-mental-model" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="get-mental-model" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="get-mental-model" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="get-mental-model" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -190,12 +210,14 @@ Re-run the source query to update the mental model with current knowledge:
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="refresh-mental-model" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="refresh-mental-model" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}/refresh"
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="refresh-mental-model" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="refresh-mental-model" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -214,14 +236,14 @@ Update the mental model's name:
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="update-mental-model" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="update-mental-model" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Updated Team Communication Preferences"}'
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="update-mental-model" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="update-mental-model" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -233,12 +255,14 @@ curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{men
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="delete-mental-model" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="delete-mental-model" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
|
||||
```
|
||||
|
||||
<CodeSnippet code={mentalModelsSh} section="delete-mental-model" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="delete-mental-model" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -280,6 +304,15 @@ Every time a mental model's content changes (via refresh or manual update), the
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={mentalModelsMjs} section="get-mental-model-history" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={mentalModelsSh} section="get-mental-model-history" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={mentalModelsGo} section="get-mental-model-history" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Response
|
||||
|
||||
@@ -9,12 +9,13 @@ Get up and running with Hindsight in 60 seconds.
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import {ClientsGrid, IntegrationsGrid} from '@site/src/components/SupportedGrids';
|
||||
import {ClientsGrid} from '@site/src/components/SupportedGrids';
|
||||
|
||||
{/* Import raw source files */}
|
||||
import quickstartPy from '!!raw-loader!@site/examples/api/quickstart.py';
|
||||
import quickstartMjs from '!!raw-loader!@site/examples/api/quickstart.mjs';
|
||||
import quickstartSh from '!!raw-loader!@site/examples/api/quickstart.sh';
|
||||
import quickstartGo from '!!raw-loader!@site/examples/api/quickstart.go';
|
||||
|
||||
## Clients
|
||||
|
||||
@@ -90,6 +91,15 @@ curl -fsSL https://hindsight.vectorize.io/get-cli | bash
|
||||
|
||||
<CodeSnippet code={quickstartSh} section="quickstart-full" language="bash" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
|
||||
```bash
|
||||
go get github.com/vectorize-io/hindsight/hindsight-clients/go
|
||||
```
|
||||
|
||||
<CodeSnippet code={quickstartGo} section="quickstart-full" language="go" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -107,7 +117,7 @@ curl -fsSL https://hindsight.vectorize.io/get-cli | bash
|
||||
|
||||
## Integrations
|
||||
|
||||
<IntegrationsGrid />
|
||||
Browse all supported integrations in the [Integrations Hub](/integrations).
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import recallPy from '!!raw-loader!@site/examples/api/recall.py';
|
||||
import recallMjs from '!!raw-loader!@site/examples/api/recall.mjs';
|
||||
import recallSh from '!!raw-loader!@site/examples/api/recall.sh';
|
||||
import recallGo from '!!raw-loader!@site/examples/api/recall.go';
|
||||
|
||||
:::info How Recall Works
|
||||
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
|
||||
@@ -37,6 +38,9 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-basic" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-basic" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
@@ -59,9 +63,19 @@ Each type runs the full four-strategy retrieval pipeline independently, so narro
|
||||
<CodeSnippet code={recallPy} section="recall-experience-only" language="python" />
|
||||
<CodeSnippet code={recallPy} section="recall-observations-only" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-world-only" language="javascript" />
|
||||
<CodeSnippet code={recallMjs} section="recall-experience-only" language="javascript" />
|
||||
<CodeSnippet code={recallMjs} section="recall-observations-only" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-fact-type" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-world-only" language="go" />
|
||||
<CodeSnippet code={recallGo} section="recall-experience-only" language="go" />
|
||||
<CodeSnippet code={recallGo} section="recall-observations-only" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip About Observations
|
||||
@@ -79,6 +93,12 @@ Controls retrieval depth and breadth. Accepted values are `low`, `mid` (default)
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-budget-levels" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-budget-levels" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-budget-levels" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### max_tokens
|
||||
@@ -89,6 +109,15 @@ The maximum number of tokens the returned facts can collectively occupy. Default
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={recallPy} section="recall-token-budget" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-token-budget" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-token-budget" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-token-budget" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### query_timestamp
|
||||
@@ -118,6 +147,12 @@ When enabled and `types` includes `observation`, each observation result is acco
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-source-facts" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-source-facts" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-source-facts" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### entities
|
||||
@@ -152,7 +187,20 @@ Consider a bank with these four memories:
|
||||
|
||||
Returns memories that have **at least one** matching tag, plus untagged memories.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={recallPy} section="recall-tags-any" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-tags-any" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-tags-any" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-with-tags" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Use this for **shared global knowledge + user-specific** patterns, where untagged memories represent information everyone should see.
|
||||
|
||||
@@ -160,7 +208,20 @@ Use this for **shared global knowledge + user-specific** patterns, where untagge
|
||||
|
||||
Same as `any` but untagged memories are excluded.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={recallPy} section="recall-tags-any-strict" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-tags-any-strict" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-tags-any-strict" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-tags-strict" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Use this when memories are **fully partitioned by tags** and untagged memories should never be visible.
|
||||
|
||||
@@ -168,7 +229,20 @@ Use this when memories are **fully partitioned by tags** and untagged memories s
|
||||
|
||||
Returns memories that have **every** specified tag, plus untagged memories.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={recallPy} section="recall-tags-all-mode" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-tags-all-mode" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-tags-all-mode" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-tags-all-mode" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Use this when memories must belong to a **specific intersection** of scopes (e.g., only memories relevant to both a user and a project), while still surfacing shared global knowledge.
|
||||
|
||||
@@ -176,7 +250,20 @@ Use this when memories must belong to a **specific intersection** of scopes (e.g
|
||||
|
||||
Returns memories that have **every** specified tag, and excludes untagged memories.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={recallPy} section="recall-tags-all-strict" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={recallMjs} section="recall-tags-all-strict" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={recallSh} section="recall-tags-all-strict" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={recallGo} section="recall-tags-all" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Use this for strict scope enforcement where a memory must explicitly belong to **all** specified contexts.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import reflectPy from '!!raw-loader!@site/examples/api/reflect.py';
|
||||
import reflectMjs from '!!raw-loader!@site/examples/api/reflect.mjs';
|
||||
import reflectSh from '!!raw-loader!@site/examples/api/reflect.sh';
|
||||
import reflectGo from '!!raw-loader!@site/examples/api/reflect.go';
|
||||
|
||||
:::info How Reflect Works
|
||||
Learn about disposition-driven reasoning in the [Reflect Architecture](/developer/reflect) guide.
|
||||
@@ -37,6 +38,9 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={reflectSh} section="reflect-basic" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={reflectGo} section="reflect-basic" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
@@ -58,6 +62,12 @@ Controls how thoroughly the agent explores the memory bank before answering. Acc
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={reflectMjs} section="reflect-with-params" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={reflectSh} section="reflect-with-params" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={reflectGo} section="reflect-with-params" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### max_tokens
|
||||
@@ -78,6 +88,9 @@ An optional JSON Schema object. When provided, the LLM generates a response that
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={reflectSh} section="reflect-structured-output" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={reflectGo} section="reflect-structured-output" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### tags
|
||||
@@ -88,6 +101,15 @@ Filters which memories the agent can access during reflection. Works identically
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={reflectPy} section="reflect-with-tags" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={reflectMjs} section="reflect-with-tags" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={reflectSh} section="reflect-with-tags" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={reflectGo} section="reflect-with-tags" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### include
|
||||
@@ -102,6 +124,15 @@ When enabled, the response includes a `based_on` object listing the memories, me
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={reflectPy} section="reflect-sources" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={reflectMjs} section="reflect-sources" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={reflectSh} section="reflect-sources" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={reflectGo} section="reflect-sources" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### include.tool_calls
|
||||
|
||||
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import retainPy from '!!raw-loader!@site/examples/api/retain.py';
|
||||
import retainMjs from '!!raw-loader!@site/examples/api/retain.mjs';
|
||||
import retainSh from '!!raw-loader!@site/examples/api/retain.sh';
|
||||
import retainGo from '!!raw-loader!@site/examples/api/retain.go';
|
||||
|
||||
:::info How Retain Works
|
||||
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
|
||||
@@ -39,6 +40,9 @@ A single retain call accepts one or more **items**. Each item is a piece of raw
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-basic" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-basic" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Retaining a Conversation
|
||||
@@ -52,6 +56,12 @@ A full conversation should be retained as a single item. The LLM can parse any f
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={retainMjs} section="retain-conversation" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-conversation" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-conversation" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
When the conversation grows — a new message arrives — just retain again with the full updated content and the same `document_id`. Hindsight will delete the previous version and reprocess from scratch, so memories always reflect the latest state of the conversation.
|
||||
@@ -92,6 +102,9 @@ Providing context consistently is one of the highest-leverage things you can do
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-with-context" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-with-context" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### metadata
|
||||
@@ -203,6 +216,12 @@ Multiple items can be submitted in a single request. Batch ingestion is the reco
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={retainMjs} section="retain-batch" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-batch" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-batch" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -215,18 +234,18 @@ Upload files directly — Hindsight converts them to text and extracts memories
|
||||
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF, etc. — OCR), audio (MP3, WAV, FLAC, etc. — transcription), HTML, and plain text formats (TXT, MD, CSV, JSON, YAML, etc.)
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="HTTP">
|
||||
<CodeSnippet code={retainSh} section="retain-files-curl" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={retainPy} section="retain-files" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={retainMjs} section="retain-files" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-files" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The file retain endpoint always returns asynchronously. The response contains `operation_ids` — one per uploaded file — which you can poll via `GET /v1/default/banks/{bank_id}/operations` to track progress.
|
||||
@@ -237,6 +256,15 @@ Upload up to 10 files per request (max 100 MB total). Each file becomes a separa
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={retainPy} section="retain-files-batch" language="python" />
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={retainMjs} section="retain-files-batch" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-files" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::info File Storage
|
||||
@@ -256,6 +284,12 @@ For large batches, use async ingestion to avoid blocking your application:
|
||||
<TabItem value="node" label="Node.js">
|
||||
<CodeSnippet code={retainMjs} section="retain-async" language="javascript" />
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
<CodeSnippet code={retainSh} section="retain-async" language="bash" />
|
||||
</TabItem>
|
||||
<TabItem value="go" label="Go">
|
||||
<CodeSnippet code={retainGo} section="retain-async" language="go" />
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
When `async: true`, the call returns immediately with an `operation_id`. Processing runs in the background via the worker service. No `usage` metrics are returned for async operations.
|
||||
|
||||
@@ -892,6 +892,7 @@ export HINDSIGHT_API_OBSERVATIONS_MISSION="Observations are recurring patterns i
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` | Max tool call iterations before forcing a response | `10` |
|
||||
| `HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS` | Max accumulated context tokens in the reflect loop before forcing final synthesis. Prevents `context_length_exceeded` errors on large banks. Lower this if your LLM has a context window smaller than 128K. | `100000` |
|
||||
| `HINDSIGHT_API_REFLECT_WALL_TIMEOUT` | Wall-clock timeout in seconds for the entire reflect operation. If exceeded, the request returns HTTP 504. | `300` |
|
||||
| `HINDSIGHT_API_REFLECT_MISSION` | Global reflect mission (identity and reasoning framing). Overridden per bank via config API. | - |
|
||||
|
||||
#### Disposition
|
||||
|
||||
@@ -3,7 +3,7 @@ sidebar_position: 1
|
||||
slug: /
|
||||
---
|
||||
|
||||
import {ClientsGrid, IntegrationsGrid} from '@site/src/components/SupportedGrids';
|
||||
import {ClientsGrid} from '@site/src/components/SupportedGrids';
|
||||
|
||||
# Overview
|
||||
|
||||
@@ -122,7 +122,7 @@ These settings only affect the `reflect` operation, not `recall`.
|
||||
|
||||
## Integrations
|
||||
|
||||
<IntegrationsGrid />
|
||||
Browse all supported integrations in the [Integrations Hub](/integrations).
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ sidebar_position: 4
|
||||
|
||||
The `@vectorize-io/hindsight-ai-sdk` package integrates [Hindsight](https://hindsight.vectorize.io) memory with the [Vercel AI SDK](https://ai-sdk.dev). It provides five ready-to-use tools for retaining, recalling, and reflecting on long-term memories.
|
||||
|
||||
[View Changelog →](/changelog/integrations/ai-sdk)
|
||||
|
||||
import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
|
||||
import aiSdkTs from '!!raw-loader!@site/examples/integrations/ai-sdk.ts';
|
||||
|
||||
@@ -6,6 +6,8 @@ sidebar_position: 5
|
||||
|
||||
We built `@vectorize-io/hindsight-chat` to give [Vercel Chat SDK](https://github.com/vercel/chat) bots persistent, per-user memory with a single handler wrapper. The integration works across Slack, Discord, Teams, Google Chat, GitHub, and Linear — no custom plumbing required.
|
||||
|
||||
[View Changelog →](/changelog/integrations/chat)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,6 +6,8 @@ sidebar_position: 5
|
||||
|
||||
Persistent memory for AI agent crews via [CrewAI](https://github.com/crewAIInc/crewAI). Give your crews long-term memory with fact extraction, entity tracking, and temporal awareness.
|
||||
|
||||
[View Changelog →](/changelog/integrations/crewai)
|
||||
|
||||
## Features
|
||||
|
||||
- **Drop-in Storage Backend** - Implements CrewAI's `Storage` interface for `ExternalMemory`
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# LangGraph / LangChain
|
||||
|
||||
Persistent long-term memory for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com/) agents via Hindsight. Three integration patterns at different abstraction levels — the tools pattern works with both LangChain and LangGraph, while nodes and the BaseStore adapter are LangGraph-specific.
|
||||
|
||||
[View Changelog →](/changelog/integrations/langgraph)
|
||||
|
||||
## Features
|
||||
|
||||
- **Memory Tools** — retain, recall, and reflect as LangChain `@tool` functions compatible with `bind_tools()` and `ToolNode`. Works with **both LangChain and LangGraph** — no LangGraph dependency required for this pattern.
|
||||
- **Graph Nodes** *(LangGraph)* — Pre-built nodes that auto-inject memories before LLM calls and auto-store after responses
|
||||
- **BaseStore Adapter** *(LangGraph)* — Drop-in `BaseStore` implementation backed by Hindsight, for LangGraph's native memory patterns
|
||||
- **Dynamic Banks** — Resolve bank IDs per-request from `RunnableConfig` for per-user memory
|
||||
- **Async-Native** — Uses `aretain`, `arecall`, `areflect` directly — no thread-pool workarounds
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-langgraph
|
||||
```
|
||||
|
||||
## Quick Start: Tools (LangChain & LangGraph)
|
||||
|
||||
The tools pattern creates standard LangChain `@tool` functions that work with any LangChain-compatible model via `bind_tools()`. You can use them with a LangGraph agent or with plain LangChain — no LangGraph required.
|
||||
|
||||
**With LangGraph (recommended):**
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_langgraph import create_hindsight_tools
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
tools = create_hindsight_tools(client=client, bank_id="user-123")
|
||||
|
||||
agent = create_react_agent(ChatOpenAI(model="gpt-4o"), tools=tools)
|
||||
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]}
|
||||
)
|
||||
```
|
||||
|
||||
**With plain LangChain:**
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_langgraph import create_hindsight_tools
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
tools = create_hindsight_tools(client=client, bank_id="user-123")
|
||||
|
||||
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
|
||||
response = await model.ainvoke("Remember that I prefer dark mode")
|
||||
```
|
||||
|
||||
When using plain LangChain, you handle the tool execution loop yourself — call the model, check for `tool_calls`, execute them, and feed results back. LangGraph automates this loop for you.
|
||||
|
||||
The agent gets three tools it can call:
|
||||
|
||||
- **`hindsight_retain`** — Store information to long-term memory
|
||||
- **`hindsight_recall`** — Search long-term memory for relevant facts
|
||||
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
|
||||
|
||||
## Quick Start: Memory Nodes (LangGraph)
|
||||
|
||||
Add recall and retain nodes to your graph for automatic memory injection and storage.
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_langgraph import create_recall_node, create_retain_node
|
||||
from langgraph.graph import StateGraph, MessagesState, START, END
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
recall = create_recall_node(client=client, bank_id="user-123")
|
||||
retain = create_retain_node(client=client, bank_id="user-123")
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("recall", recall)
|
||||
builder.add_node("agent", agent_node) # your LLM node
|
||||
builder.add_node("retain", retain)
|
||||
|
||||
builder.add_edge(START, "recall")
|
||||
builder.add_edge("recall", "agent")
|
||||
builder.add_edge("agent", "retain")
|
||||
builder.add_edge("retain", END)
|
||||
|
||||
graph = builder.compile()
|
||||
```
|
||||
|
||||
The recall node extracts the latest user message, searches Hindsight, and injects matching memories as a `SystemMessage`. The retain node stores human messages (optionally AI messages too) after the response.
|
||||
|
||||
## Quick Start: BaseStore (LangGraph)
|
||||
|
||||
Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search.
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_langgraph import HindsightStore
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
store = HindsightStore(client=client)
|
||||
|
||||
graph = builder.compile(checkpointer=checkpointer, store=store)
|
||||
|
||||
# Store and search via the store API
|
||||
await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"})
|
||||
results = await store.asearch(("user", "123", "prefs"), query="theme preference")
|
||||
```
|
||||
|
||||
Namespace tuples are mapped to Hindsight bank IDs with `.` as separator (e.g., `("user", "123")` becomes bank `user.123`). Banks are auto-created on first access.
|
||||
|
||||
## Dynamic Bank IDs
|
||||
|
||||
Both nodes and the store support per-user bank resolution from `RunnableConfig`:
|
||||
|
||||
```python
|
||||
recall = create_recall_node(client=client, bank_id_from_config="user_id")
|
||||
retain = create_retain_node(client=client, bank_id_from_config="user_id")
|
||||
|
||||
# Bank ID resolved at runtime from config
|
||||
result = await graph.ainvoke(
|
||||
{"messages": [{"role": "user", "content": "hello"}]},
|
||||
config={"configurable": {"user_id": "user-456"}},
|
||||
)
|
||||
```
|
||||
|
||||
## Selecting Tools
|
||||
|
||||
Include only the tools you need:
|
||||
|
||||
```python
|
||||
tools = create_hindsight_tools(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
include_retain=True,
|
||||
include_recall=True,
|
||||
include_reflect=False, # Omit reflect
|
||||
)
|
||||
```
|
||||
|
||||
## Global Configuration
|
||||
|
||||
Instead of passing a client to every call, configure once:
|
||||
|
||||
```python
|
||||
from hindsight_langgraph import configure, create_hindsight_tools
|
||||
|
||||
configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
|
||||
budget="mid", # Recall budget: low/mid/high
|
||||
max_tokens=4096, # Max tokens for recall results
|
||||
tags=["env:prod"], # Tags for stored memories
|
||||
recall_tags=["scope:global"], # Tags to filter recall
|
||||
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
|
||||
)
|
||||
|
||||
# Now create tools without passing client — uses global config
|
||||
tools = create_hindsight_tools(bank_id="user-123")
|
||||
```
|
||||
|
||||
## Retain Node Options
|
||||
|
||||
```python
|
||||
retain = create_retain_node(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
retain_human=True, # Store human messages (default: True)
|
||||
retain_ai=False, # Store AI responses (default: False)
|
||||
tags=["source:chat"], # Tags applied to stored memories
|
||||
)
|
||||
```
|
||||
|
||||
## Recall Node Options
|
||||
|
||||
```python
|
||||
recall = create_recall_node(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
budget="low", # Recall budget: low/mid/high
|
||||
max_results=10, # Max memories injected
|
||||
max_tokens=4096, # Max tokens for recall
|
||||
tags=["scope:user"], # Filter by tags
|
||||
tags_match="all", # Tag match mode
|
||||
)
|
||||
```
|
||||
|
||||
### Using `output_key` for Prompt Control
|
||||
|
||||
By default, the recall node appends a `SystemMessage` to `messages`. Use `output_key` to write memory text to a custom state field instead, giving you full control over prompt ordering:
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
class AgentState(MessagesState):
|
||||
memory_context: Optional[str] = None
|
||||
|
||||
recall = create_recall_node(
|
||||
client=client,
|
||||
bank_id="user-123",
|
||||
output_key="memory_context",
|
||||
)
|
||||
|
||||
# In your agent node, read state["memory_context"] and prepend it
|
||||
# to the system prompt before calling the model.
|
||||
```
|
||||
|
||||
## Limitations and Notes
|
||||
|
||||
### HindsightStore
|
||||
|
||||
- **Async-only.** All sync methods (`batch`, `get`, `put`, `delete`, `search`, `list_namespaces`) raise `NotImplementedError`. Use the async variants (`abatch`, `aget`, `aput`, `adelete`, `asearch`, `alist_namespaces`) instead.
|
||||
- **`get()` relies on recall.** There is no direct key lookup — the key is used as a recall query and only exact `document_id` matches are returned. Items that do not rank in the top recall results may appear missing.
|
||||
- **`list_namespaces` is session-scoped.** It only tracks namespaces that have been written to via `aput()` during the current process. After a restart, `list_namespaces` returns empty even though data still exists in Hindsight.
|
||||
- **`delete` is a no-op.** Calling `adelete()` logs a debug message but does not remove data from Hindsight. Hindsight's memory model is append-oriented; fact superseding is handled automatically during retain.
|
||||
|
||||
### Memory Nodes
|
||||
|
||||
- **SystemMessage ordering.** The recall node adds a `SystemMessage` with recalled memories. Because `MessagesState` uses `add_messages` (which appends), this message appears after existing messages rather than at position 0. The message has a stable ID (`hindsight_memory_context`) so it is updated rather than duplicated across invocations. If your LLM provider requires system messages first, sort or filter messages in your agent node before passing them to the model.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Tools** raise `HindsightError` on failure, which surfaces to the agent as a tool error.
|
||||
- **Nodes** silently log errors and return empty messages, so a Hindsight outage does not crash your graph.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `create_hindsight_tools()`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `bank_id` | *required* | Hindsight memory bank ID |
|
||||
| `client` | `None` | Pre-configured Hindsight client |
|
||||
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
|
||||
| `api_key` | `None` | API key (used if no client provided) |
|
||||
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
|
||||
| `max_tokens` | `4096` | Maximum tokens for recall results |
|
||||
| `tags` | `None` | Tags applied when storing memories |
|
||||
| `recall_tags` | `None` | Tags to filter when searching |
|
||||
| `recall_tags_match` | `"any"` | Tag matching mode (any/all/any\_strict/all\_strict) |
|
||||
| `retain_metadata` | `None` | Default metadata dict for retain operations |
|
||||
| `retain_document_id` | `None` | Default document\_id for retain (groups/upserts memories) |
|
||||
| `recall_types` | `None` | Fact types to filter (world, experience, opinion, observation) |
|
||||
| `recall_include_entities` | `False` | Include entity information in recall results |
|
||||
| `reflect_context` | `None` | Additional context for reflect operations |
|
||||
| `reflect_max_tokens` | `None` | Max tokens for reflect results (defaults to `max_tokens`) |
|
||||
| `reflect_response_schema` | `None` | JSON schema to constrain reflect output format |
|
||||
| `reflect_tags` | `None` | Tags to filter memories used in reflect (defaults to `recall_tags`) |
|
||||
| `reflect_tags_match` | `None` | Tag matching for reflect (defaults to `recall_tags_match`) |
|
||||
| `include_retain` | `True` | Include the retain (store) tool |
|
||||
| `include_recall` | `True` | Include the recall (search) tool |
|
||||
| `include_reflect` | `True` | Include the reflect (synthesize) tool |
|
||||
|
||||
### `create_recall_node()`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
|
||||
| `client` | `None` | Pre-configured Hindsight client |
|
||||
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
|
||||
| `api_key` | `None` | API key (used if no client provided) |
|
||||
| `budget` | `"mid"` | Recall budget level |
|
||||
| `max_tokens` | `4096` | Max tokens for recall results |
|
||||
| `max_results` | `10` | Max memories to inject |
|
||||
| `tags` | `None` | Tags to filter recall results |
|
||||
| `tags_match` | `"any"` | Tag matching mode |
|
||||
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
|
||||
| `output_key` | `None` | If set, write memory text to this state key instead of appending a SystemMessage to `messages` |
|
||||
|
||||
### `create_retain_node()`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
|
||||
| `client` | `None` | Pre-configured Hindsight client |
|
||||
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
|
||||
| `api_key` | `None` | API key (used if no client provided) |
|
||||
| `tags` | `None` | Tags applied to stored memories |
|
||||
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
|
||||
| `retain_human` | `True` | Store human messages |
|
||||
| `retain_ai` | `False` | Store AI responses |
|
||||
|
||||
### `HindsightStore()`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `client` | `None` | Pre-configured Hindsight client |
|
||||
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
|
||||
| `api_key` | `None` | API key (used if no client provided) |
|
||||
| `tags` | `None` | Tags applied to all retain operations |
|
||||
|
||||
### `configure()`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `hindsight_api_url` | Production API | Hindsight API URL |
|
||||
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
|
||||
| `budget` | `"mid"` | Default recall budget level |
|
||||
| `max_tokens` | `4096` | Default max tokens for recall |
|
||||
| `tags` | `None` | Default tags for retain operations |
|
||||
| `recall_tags` | `None` | Default tags to filter recall |
|
||||
| `recall_tags_match` | `"any"` | Default tag matching mode |
|
||||
| `verbose` | `False` | Enable verbose logging |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- langchain-core >= 0.3.0
|
||||
- hindsight-client >= 0.4.0
|
||||
- langgraph >= 0.3.0 *(only for nodes and store patterns — install with `pip install hindsight-langgraph[langgraph]`)*
|
||||
@@ -6,6 +6,8 @@ sidebar_position: 1
|
||||
|
||||
Universal LLM memory integration via [LiteLLM](https://github.com/BerriAI/litellm). Add persistent memory to any LLM application with just a few lines of code.
|
||||
|
||||
[View Changelog →](/changelog/integrations/litellm)
|
||||
|
||||
## Features
|
||||
|
||||
- **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# NemoClaw
|
||||
|
||||
Persistent memory for [NemoClaw](https://nemoclaw.ai) sandboxed agents using [Hindsight](https://hindsight.vectorize.io).
|
||||
|
||||
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with controlled filesystem, process, and network egress policies. The `hindsight-nemoclaw` package automates adding Hindsight memory to a sandbox in one command — no code changes required.
|
||||
|
||||
[View Changelog →](/changelog/integrations/nemoclaw)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-nemoclaw setup \
|
||||
--sandbox my-assistant \
|
||||
--api-url https://api.hindsight.vectorize.io \
|
||||
--api-token <your-api-key> \
|
||||
--bank-prefix my-sandbox
|
||||
```
|
||||
|
||||
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
|
||||
|
||||
You'll see output like:
|
||||
|
||||
```
|
||||
[0] Preflight checks...
|
||||
✓ openshell found
|
||||
✓ openclaw found
|
||||
|
||||
[1] Installing @vectorize-io/hindsight-openclaw plugin...
|
||||
✓ Plugin installed
|
||||
|
||||
[2] Configuring plugin in ~/.openclaw/openclaw.json...
|
||||
✓ Plugin config written (bank: my-sandbox-openclaw)
|
||||
|
||||
[3] Applying Hindsight network policy to sandbox "my-assistant"...
|
||||
✓ Policy version 2 submitted
|
||||
✓ Policy version 2 loaded (active version: 2)
|
||||
|
||||
[4] Restarting OpenClaw gateway...
|
||||
✓ Gateway restarted
|
||||
|
||||
✓ Setup complete!
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### The sandbox problem
|
||||
|
||||
OpenShell enforces strict network egress — every outbound endpoint must be explicitly permitted in the sandbox policy. By default, the Hindsight API (`api.hindsight.vectorize.io`) is not in that list.
|
||||
|
||||
The `hindsight-openclaw` plugin supports **external API mode**, where it skips the local daemon entirely and makes direct HTTPS calls to Hindsight Cloud. This is the natural fit for sandboxed environments: the plugin becomes a thin HTTP client, and the only sandbox change needed is one egress rule.
|
||||
|
||||
### What the setup command does
|
||||
|
||||
1. **Preflight** — verifies `openshell` and `openclaw` are installed
|
||||
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
|
||||
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
|
||||
4. **Apply policy** — reads the current sandbox policy, merges the Hindsight egress block, and re-applies via `openshell policy set`
|
||||
5. **Restart gateway** — runs `openclaw gateway restart`
|
||||
|
||||
### Memory flow
|
||||
|
||||
Once set up, the `hindsight-openclaw` plugin hooks into the OpenClaw gateway lifecycle:
|
||||
|
||||
- **`before_agent_start`** — recalls relevant memories from past sessions and injects them into context
|
||||
- **`agent_end`** — retains the conversation to the Hindsight memory bank
|
||||
|
||||
The sandbox doesn't interfere with either step — it sees the Hindsight calls as normal HTTPS egress to a permitted endpoint.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
hindsight-nemoclaw setup [options]
|
||||
|
||||
Options:
|
||||
--sandbox <name> NemoClaw sandbox name (required)
|
||||
--api-url <url> Hindsight API URL (required)
|
||||
--api-token <token> Hindsight API token (required)
|
||||
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
|
||||
--skip-policy Skip sandbox network policy update
|
||||
--skip-plugin-install Skip openclaw plugin installation
|
||||
--dry-run Preview changes without applying
|
||||
--help Show help
|
||||
```
|
||||
|
||||
Use `--dry-run` to preview all changes before applying anything. Use `--skip-policy` if you manage sandbox policies manually.
|
||||
|
||||
## Manual Setup
|
||||
|
||||
If you prefer to apply the steps yourself instead of using the CLI:
|
||||
|
||||
### 1. Install the plugin
|
||||
|
||||
```bash
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
### 2. Configure `~/.openclaw/openclaw.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclaw": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
|
||||
"hindsightApiToken": "<your-api-key>",
|
||||
"llmProvider": "claude-code",
|
||||
"dynamicBankId": false,
|
||||
"bankIdPrefix": "my-sandbox"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`llmProvider: "claude-code"` uses the Claude Code process already present in the sandbox — no additional API key needed.
|
||||
|
||||
### 3. Add the Hindsight network policy
|
||||
|
||||
`openshell policy set` replaces the entire policy document. Export your current policy first, add the Hindsight block, then re-apply:
|
||||
|
||||
```yaml
|
||||
network_policies:
|
||||
hindsight:
|
||||
name: hindsight
|
||||
endpoints:
|
||||
- host: api.hindsight.vectorize.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: /**
|
||||
- allow:
|
||||
method: POST
|
||||
path: /**
|
||||
- allow:
|
||||
method: PUT
|
||||
path: /**
|
||||
binaries:
|
||||
- path: /usr/local/bin/openclaw
|
||||
```
|
||||
|
||||
```bash
|
||||
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `hindsightApiUrl` | string | — | Hindsight API base URL |
|
||||
| `hindsightApiToken` | string | — | API token for authentication |
|
||||
| `llmProvider` | string | auto-detect | LLM provider for memory extraction |
|
||||
| `dynamicBankId` | boolean | `false` | Isolate memory per user (`true`) or share across sessions (`false`) |
|
||||
| `bankIdPrefix` | string | `"nemoclaw"` | Prefix for the memory bank name |
|
||||
|
||||
### Bank naming
|
||||
|
||||
When `dynamicBankId: false`, all sessions write to a single bank named `{bankIdPrefix}-openclaw`. When `dynamicBankId: true`, each user gets an isolated bank — useful for multi-tenant deployments.
|
||||
|
||||
## Verifying It Works
|
||||
|
||||
After setup, check the gateway logs:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
|
||||
```
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```
|
||||
[Hindsight] Plugin loaded successfully
|
||||
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
|
||||
[Hindsight] External API health: {"status":"healthy","database":"connected"}
|
||||
[Hindsight] Default bank: my-sandbox-openclaw
|
||||
[Hindsight] ✓ Ready (external API mode)
|
||||
```
|
||||
|
||||
After a conversation:
|
||||
|
||||
```
|
||||
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
|
||||
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
|
||||
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Policy replacement is full-document
|
||||
|
||||
`openshell policy set` replaces the entire policy document. The `hindsight-nemoclaw setup` command handles this automatically. If you're applying manually, export the current policy first so existing rules aren't lost.
|
||||
|
||||
### LaunchAgent can't follow symlinks on macOS
|
||||
|
||||
On macOS, the OpenClaw gateway runs as a LaunchAgent under a restricted security context. `openclaw plugins install --link` creates a symlink the LaunchAgent can't follow — the setup command installs as a copy instead. If you see `EPERM: operation not permitted, scandir` in gateway logs, this is the cause.
|
||||
|
||||
### Memory retention is asynchronous
|
||||
|
||||
Fact extraction and entity resolution happen in the background after `retain`. If you open a new session immediately after closing one, the most recent memories may not be indexed yet — typically a few seconds.
|
||||
|
||||
### Binary-scoped egress
|
||||
|
||||
The `binaries` field in the network policy restricts the egress rule to a specific executable path. If OpenClaw updates and the binary path changes, the rule silently stops working. Check your binary path after upgrades.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin not loading
|
||||
|
||||
```bash
|
||||
openclaw plugins list | grep hindsight
|
||||
# Should show: ✓ enabled │ Hindsight Memory │ ...
|
||||
|
||||
# Reinstall
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
### Egress blocked
|
||||
|
||||
If calls to `api.hindsight.vectorize.io` are being blocked, check the active sandbox policy:
|
||||
|
||||
```bash
|
||||
openshell sandbox get my-assistant
|
||||
```
|
||||
|
||||
Verify the `hindsight` block is present and the `binaries` path matches your OpenClaw binary:
|
||||
|
||||
```bash
|
||||
which openclaw
|
||||
```
|
||||
|
||||
### External API not connecting
|
||||
|
||||
```bash
|
||||
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
|
||||
|
||||
# If you see daemon startup messages instead of "Using external API",
|
||||
# the plugin config isn't being read — check ~/.openclaw/openclaw.json
|
||||
```
|
||||
@@ -8,6 +8,8 @@ Local, long term memory for [OpenClaw](https://openclaw.ai) agents using [Hindsi
|
||||
|
||||
This plugin integrates [hindsight-embed](https://vectorize.io/hindsight/cli), a standalone daemon that bundles Hindsight's memory engine (API + PostgreSQL) into a single command. Everything runs locally on your machine, reuses the LLM you're already paying for, and costs nothing extra.
|
||||
|
||||
[View Changelog →](/changelog/integrations/openclaw)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Step 1: Set up LLM for memory extraction**
|
||||
|
||||
@@ -6,6 +6,8 @@ sidebar_position: 6
|
||||
|
||||
Persistent memory tools for [Pydantic AI](https://ai.pydantic.dev/) agents via Hindsight. Give your agents long-term memory with retain, recall, and reflect — all async-native with no thread-pool hacks.
|
||||
|
||||
[View Changelog →](/changelog/integrations/pydantic-ai)
|
||||
|
||||
## Features
|
||||
|
||||
- **Async-Native Tools** — Uses Pydantic AI's async tool interface directly (`aretain`, `arecall`, `areflect`)
|
||||
|
||||
@@ -209,10 +209,10 @@ const config: Config = {
|
||||
className: 'navbar-item-developer',
|
||||
},
|
||||
{
|
||||
to: '/faq',
|
||||
to: '/integrations',
|
||||
position: 'left',
|
||||
label: 'FAQ',
|
||||
className: 'navbar-item-faq',
|
||||
label: 'Integrations',
|
||||
className: 'navbar-item-integrations',
|
||||
},
|
||||
{
|
||||
to: '/changelog',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
const bankID = "directives-example-bank"
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Setup (not shown in docs)
|
||||
// =============================================================================
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, bankID).
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
Name: *hindsight.NewNullableString(hindsight.PtrString("Test Bank")),
|
||||
}).Execute()
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:create-directive]
|
||||
// Create a directive (hard rule for reflect)
|
||||
directive, _, _ := client.DirectivesAPI.CreateDirective(ctx, bankID).
|
||||
CreateDirectiveRequest(hindsight.CreateDirectiveRequest{
|
||||
Name: "Formal Language",
|
||||
Content: "Always respond in formal English, avoiding slang and colloquialisms.",
|
||||
}).Execute()
|
||||
|
||||
fmt.Printf("Created directive: %s\n", directive.GetId())
|
||||
// [/docs:create-directive]
|
||||
|
||||
directiveID := directive.GetId()
|
||||
|
||||
// [docs:list-directives]
|
||||
// List all directives in a bank
|
||||
directives, _, _ := client.DirectivesAPI.ListDirectives(ctx, bankID).Execute()
|
||||
|
||||
for _, d := range directives.GetItems() {
|
||||
content := d.GetContent()
|
||||
if len(content) > 50 {
|
||||
content = content[:50]
|
||||
}
|
||||
fmt.Printf("- %s: %s...\n", d.GetName(), content)
|
||||
}
|
||||
// [/docs:list-directives]
|
||||
|
||||
// [docs:update-directive]
|
||||
// Update a directive (e.g., disable without deleting)
|
||||
isActiveFalse := false
|
||||
updated, _, _ := client.DirectivesAPI.UpdateDirective(ctx, bankID, directiveID).
|
||||
UpdateDirectiveRequest(hindsight.UpdateDirectiveRequest{
|
||||
IsActive: *hindsight.NewNullableBool(&isActiveFalse),
|
||||
}).Execute()
|
||||
|
||||
fmt.Printf("Directive active: %v\n", updated.GetIsActive())
|
||||
// [/docs:update-directive]
|
||||
|
||||
// [docs:delete-directive]
|
||||
// Delete a directive
|
||||
client.DirectivesAPI.DeleteDirective(ctx, bankID, directiveID).Execute()
|
||||
// [/docs:delete-directive]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
|
||||
fmt.Println("directives.go: All examples passed")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Directives API examples for Hindsight CLI
|
||||
# Run: bash examples/api/directives.sh
|
||||
|
||||
set -e
|
||||
|
||||
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
|
||||
BANK_ID="directives-example-bank"
|
||||
|
||||
# =============================================================================
|
||||
# Setup (not shown in docs)
|
||||
# =============================================================================
|
||||
hindsight bank create "$BANK_ID" --name "Test Bank"
|
||||
|
||||
# =============================================================================
|
||||
# Doc Examples
|
||||
# =============================================================================
|
||||
|
||||
# [docs:create-directive]
|
||||
# Create a directive (hard rule for reflect)
|
||||
hindsight directive create "$BANK_ID" \
|
||||
"Formal Language" \
|
||||
"Always respond in formal English, avoiding slang and colloquialisms."
|
||||
# [/docs:create-directive]
|
||||
|
||||
# Get the directive ID for subsequent operations
|
||||
DIRECTIVE_ID=$(hindsight directive list "$BANK_ID" -o json | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
|
||||
|
||||
# [docs:list-directives]
|
||||
# List all directives in a bank
|
||||
hindsight directive list "$BANK_ID"
|
||||
# [/docs:list-directives]
|
||||
|
||||
if [ -n "$DIRECTIVE_ID" ]; then
|
||||
# [docs:update-directive]
|
||||
# Update a directive (e.g., disable without deleting)
|
||||
hindsight directive update "$BANK_ID" "$DIRECTIVE_ID" --is-active false
|
||||
# [/docs:update-directive]
|
||||
|
||||
# [docs:delete-directive]
|
||||
# Delete a directive
|
||||
hindsight directive delete "$BANK_ID" "$DIRECTIVE_ID" -y
|
||||
# [/docs:delete-directive]
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup (not shown in docs)
|
||||
# =============================================================================
|
||||
hindsight bank delete "$BANK_ID" -y
|
||||
|
||||
echo "directives.sh: All examples passed"
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// [docs:document-retain]
|
||||
// Retain with document ID
|
||||
docID := "meeting-2024-03-15"
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{
|
||||
Content: "Alice presented the Q4 roadmap...",
|
||||
DocumentId: *hindsight.NewNullableString(&docID),
|
||||
},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:document-retain]
|
||||
|
||||
// [docs:document-update]
|
||||
// Original
|
||||
planDoc := "project-plan"
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{
|
||||
Content: "Project deadline: March 31",
|
||||
DocumentId: *hindsight.NewNullableString(&planDoc),
|
||||
},
|
||||
},
|
||||
}).Execute()
|
||||
|
||||
// Update (deletes old facts, creates new ones)
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{
|
||||
Content: "Project deadline: April 15 (extended)",
|
||||
DocumentId: *hindsight.NewNullableString(&planDoc),
|
||||
},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:document-update]
|
||||
|
||||
// [docs:document-get]
|
||||
doc, _, err := client.DocumentsAPI.GetDocument(ctx, "my-bank", "meeting-2024-03-15").Execute()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get document: %v", err)
|
||||
}
|
||||
fmt.Printf("Document ID: %s\n", doc.GetId())
|
||||
fmt.Printf("Memory units: %d\n", doc.GetMemoryUnitCount())
|
||||
// [/docs:document-get]
|
||||
|
||||
// [docs:document-delete]
|
||||
client.DocumentsAPI.DeleteDocument(ctx, "my-bank", "meeting-2024-03-15").Execute()
|
||||
// [/docs:document-delete]
|
||||
|
||||
// [docs:document-list]
|
||||
// List all documents
|
||||
docs, _, err := client.DocumentsAPI.ListDocuments(ctx, "my-bank").Execute()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list documents: %v", err)
|
||||
}
|
||||
for _, d := range docs.Items {
|
||||
id, _ := d["id"].(string)
|
||||
memCount, _ := d["memory_unit_count"].(float64)
|
||||
fmt.Printf("%s: %d memories\n", id, int(memCount))
|
||||
}
|
||||
// [/docs:document-list]
|
||||
|
||||
// Cleanup (not shown in docs)
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
|
||||
fmt.Println("documents.go: All examples passed")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// [docs:main-retain]
|
||||
// Store a fact or conversation into a memory bank
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{Content: "Alice joined Google in March 2024 as a Senior ML Engineer"},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:main-retain]
|
||||
|
||||
// [docs:main-recall]
|
||||
// Search for memories using a natural language query
|
||||
resp, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What does Alice do at Google?",
|
||||
}).Execute()
|
||||
|
||||
for _, r := range resp.Results {
|
||||
fmt.Println(r.Text)
|
||||
}
|
||||
// [/docs:main-recall]
|
||||
|
||||
// [docs:main-reflect]
|
||||
// Generate a reasoned response using memories and bank disposition
|
||||
answer, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "Should we adopt TypeScript for our backend?",
|
||||
}).Execute()
|
||||
|
||||
fmt.Println(answer.GetText())
|
||||
// [/docs:main-reflect]
|
||||
|
||||
// Cleanup (not shown in docs)
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
|
||||
fmt.Println("main-methods.go: All examples passed")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:create-bank]
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{}).Execute()
|
||||
// [/docs:create-bank]
|
||||
|
||||
// [docs:bank-with-disposition]
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "architect-bank").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
|
||||
"You're a senior software architect - keep track of system designs, " +
|
||||
"technology decisions, and architectural patterns. Prefer simplicity over cutting-edge.",
|
||||
)),
|
||||
DispositionSkepticism: *hindsight.NewNullableInt32(hindsight.PtrInt32(4)),
|
||||
DispositionLiteralism: *hindsight.NewNullableInt32(hindsight.PtrInt32(4)),
|
||||
DispositionEmpathy: *hindsight.NewNullableInt32(hindsight.PtrInt32(2)),
|
||||
}).Execute()
|
||||
// [/docs:bank-with-disposition]
|
||||
|
||||
// [docs:bank-background]
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
|
||||
"I am a research assistant specializing in machine learning.",
|
||||
)),
|
||||
}).Execute()
|
||||
// [/docs:bank-background]
|
||||
|
||||
// [docs:bank-mission]
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
|
||||
"You're a senior software architect - keep track of system designs, " +
|
||||
"technology decisions, and architectural patterns.",
|
||||
)),
|
||||
}).Execute()
|
||||
// [/docs:bank-mission]
|
||||
|
||||
// [docs:bank-support-agent]
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "support-bank").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{}).Execute()
|
||||
client.BanksAPI.UpdateBankConfig(ctx, "support-bank").
|
||||
BankConfigUpdate(hindsight.BankConfigUpdate{
|
||||
Updates: map[string]interface{}{
|
||||
"observations_mission": "I am a customer support agent. Track customer preferences, " +
|
||||
"recurring issues, and resolution history to provide consistent, personalized support.",
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:bank-support-agent]
|
||||
|
||||
// [docs:update-bank-config]
|
||||
client.BanksAPI.UpdateBankConfig(ctx, "my-bank").
|
||||
BankConfigUpdate(hindsight.BankConfigUpdate{
|
||||
Updates: map[string]interface{}{
|
||||
"retain_mission": "Always include technical decisions, API design choices, and architectural trade-offs. " +
|
||||
"Ignore meeting logistics and social exchanges.",
|
||||
"retain_extraction_mode": "verbose",
|
||||
"observations_mission": "Observations are stable facts about people and projects. " +
|
||||
"Always include preferences, skills, and recurring patterns. Ignore one-off events.",
|
||||
"disposition_skepticism": 4,
|
||||
"disposition_literalism": 4,
|
||||
"disposition_empathy": 2,
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:update-bank-config]
|
||||
|
||||
// [docs:get-bank-config]
|
||||
// Returns resolved config (server defaults merged with bank overrides) and the raw overrides
|
||||
result, _, _ := client.BanksAPI.GetBankConfig(ctx, "my-bank").Execute()
|
||||
// result.Config — full resolved configuration
|
||||
// result.Overrides — only fields overridden at the bank level
|
||||
fmt.Println("Config keys:", len(result.GetConfig()))
|
||||
// [/docs:get-bank-config]
|
||||
|
||||
// [docs:reset-bank-config]
|
||||
// Remove all bank-level overrides, reverting to server defaults
|
||||
client.BanksAPI.ResetBankConfig(ctx, "my-bank").Execute()
|
||||
// [/docs:reset-bank-config]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
for _, bankID := range []string{"my-bank", "architect-bank", "support-bank"} {
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
fmt.Println("memory-banks.go: All examples passed")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Memory Banks API examples for Hindsight CLI
|
||||
# Run: bash examples/api/memory-banks.sh
|
||||
|
||||
set -e
|
||||
|
||||
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
|
||||
|
||||
# =============================================================================
|
||||
# Doc Examples
|
||||
# =============================================================================
|
||||
|
||||
# [docs:create-bank]
|
||||
hindsight bank create my-bank
|
||||
# [/docs:create-bank]
|
||||
|
||||
# [docs:bank-with-disposition]
|
||||
hindsight bank create architect-bank \
|
||||
--mission "You're a senior software architect - keep track of system designs, technology decisions, and architectural patterns. Prefer simplicity over cutting-edge." \
|
||||
--skepticism 4 \
|
||||
--literalism 4 \
|
||||
--empathy 2
|
||||
# [/docs:bank-with-disposition]
|
||||
|
||||
# [docs:bank-background]
|
||||
hindsight bank create my-bank \
|
||||
--mission "I am a research assistant specializing in machine learning."
|
||||
# [/docs:bank-background]
|
||||
|
||||
# [docs:bank-mission]
|
||||
hindsight bank create my-bank \
|
||||
--mission "You're a senior software architect - keep track of system designs, technology decisions, and architectural patterns."
|
||||
# [/docs:bank-mission]
|
||||
|
||||
# [docs:bank-support-agent]
|
||||
hindsight bank create support-bank
|
||||
hindsight bank set-config support-bank \
|
||||
--observations-mission "I am a customer support agent. Track customer preferences, recurring issues, and resolution history."
|
||||
# [/docs:bank-support-agent]
|
||||
|
||||
# [docs:update-bank-config]
|
||||
hindsight bank set-config my-bank \
|
||||
--retain-mission "Always include technical decisions, API design choices, and architectural trade-offs. Ignore meeting logistics and social exchanges." \
|
||||
--retain-extraction-mode verbose \
|
||||
--observations-mission "Observations are stable facts about people and projects. Always include preferences, skills, and recurring patterns. Ignore one-off events." \
|
||||
--disposition-skepticism 4 \
|
||||
--disposition-literalism 4 \
|
||||
--disposition-empathy 2
|
||||
# [/docs:update-bank-config]
|
||||
|
||||
# [docs:get-bank-config]
|
||||
# Returns resolved config (server defaults merged with bank overrides)
|
||||
hindsight bank config my-bank
|
||||
|
||||
# Show only bank-specific overrides
|
||||
hindsight bank config my-bank --overrides-only
|
||||
# [/docs:get-bank-config]
|
||||
|
||||
# [docs:reset-bank-config]
|
||||
# Remove all bank-level overrides, reverting to server defaults
|
||||
hindsight bank reset-config my-bank -y
|
||||
# [/docs:reset-bank-config]
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup (not shown in docs)
|
||||
# =============================================================================
|
||||
for bank_id in my-bank architect-bank support-bank; do
|
||||
curl -s -X DELETE "${HINDSIGHT_URL}/v1/default/banks/${bank_id}" > /dev/null
|
||||
done
|
||||
|
||||
echo "memory-banks.sh: All examples passed"
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
const mmBankID = "mental-models-demo-bank"
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Setup (not shown in docs)
|
||||
// =============================================================================
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, mmBankID).
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
Name: *hindsight.NewNullableString(hindsight.PtrString("Mental Models Demo")),
|
||||
}).Execute()
|
||||
for _, content := range []string{
|
||||
"The team prefers async communication via Slack",
|
||||
"For urgent issues, use the #incidents channel",
|
||||
"Weekly syncs happen every Monday at 10am",
|
||||
} {
|
||||
client.MemoryAPI.RetainMemories(ctx, mmBankID).
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{{Content: content}},
|
||||
}).Execute()
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:create-mental-model]
|
||||
// Create a mental model (runs reflect in background)
|
||||
result, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
|
||||
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
|
||||
Name: "Team Communication Preferences",
|
||||
SourceQuery: "How does the team prefer to communicate?",
|
||||
Tags: []string{"team", "communication"},
|
||||
}).Execute()
|
||||
|
||||
// Returns an operation_id — check operations endpoint for completion
|
||||
fmt.Printf("Operation ID: %s\n", result.GetOperationId())
|
||||
// [/docs:create-mental-model]
|
||||
|
||||
// [docs:create-mental-model-with-id]
|
||||
// Create a mental model with a specific custom ID
|
||||
mmID := "communication-policy"
|
||||
resultWithID, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
|
||||
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
|
||||
Id: *hindsight.NewNullableString(&mmID),
|
||||
Name: "Communication Policy",
|
||||
SourceQuery: "What are the team's communication guidelines?",
|
||||
}).Execute()
|
||||
|
||||
fmt.Printf("Created with custom ID: %s\n", resultWithID.GetOperationId())
|
||||
// [/docs:create-mental-model-with-id]
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// [docs:create-mental-model-with-trigger]
|
||||
// Create a mental model with automatic refresh enabled
|
||||
refreshTrue := true
|
||||
result2, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
|
||||
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
|
||||
Name: "Project Status",
|
||||
SourceQuery: "What is the current project status?",
|
||||
Trigger: &hindsight.MentalModelTrigger{
|
||||
RefreshAfterConsolidation: &refreshTrue,
|
||||
},
|
||||
}).Execute()
|
||||
|
||||
// This mental model will automatically refresh when observations are updated
|
||||
fmt.Printf("Operation ID: %s\n", result2.GetOperationId())
|
||||
// [/docs:create-mental-model-with-trigger]
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// [docs:list-mental-models]
|
||||
// List all mental models in a bank
|
||||
mentalModels, _, _ := client.MentalModelsAPI.ListMentalModels(ctx, mmBankID).Execute()
|
||||
|
||||
for _, mm := range mentalModels.GetItems() {
|
||||
fmt.Printf("- %s: %s\n", mm.GetName(), mm.GetSourceQuery())
|
||||
}
|
||||
// [/docs:list-mental-models]
|
||||
|
||||
if len(mentalModels.GetItems()) == 0 {
|
||||
fmt.Println("mental-models.go: All examples passed (no mental models created yet)")
|
||||
cleanupMentalModels(client, ctx, apiURL)
|
||||
return
|
||||
}
|
||||
|
||||
mentalModelID := mentalModels.GetItems()[0].GetId()
|
||||
|
||||
// [docs:get-mental-model]
|
||||
// Get a specific mental model
|
||||
mentalModel, _, _ := client.MentalModelsAPI.GetMentalModel(ctx, mmBankID, mentalModelID).Execute()
|
||||
|
||||
fmt.Printf("Name: %s\n", mentalModel.GetName())
|
||||
fmt.Printf("Content: %s\n", mentalModel.GetContent())
|
||||
fmt.Printf("Last refreshed: %s\n", mentalModel.GetLastRefreshedAt())
|
||||
// [/docs:get-mental-model]
|
||||
|
||||
// [docs:refresh-mental-model]
|
||||
// Refresh a mental model to update with current knowledge
|
||||
refreshResult, _, _ := client.MentalModelsAPI.RefreshMentalModel(ctx, mmBankID, mentalModelID).Execute()
|
||||
|
||||
fmt.Printf("Refresh operation ID: %s\n", refreshResult.GetOperationId())
|
||||
// [/docs:refresh-mental-model]
|
||||
|
||||
// [docs:update-mental-model]
|
||||
// Update a mental model's metadata
|
||||
newName := "Updated Team Communication Preferences"
|
||||
refreshAfter := true
|
||||
updated, _, _ := client.MentalModelsAPI.UpdateMentalModel(ctx, mmBankID, mentalModelID).
|
||||
UpdateMentalModelRequest(hindsight.UpdateMentalModelRequest{
|
||||
Name: *hindsight.NewNullableString(&newName),
|
||||
Trigger: *hindsight.NewNullableMentalModelTrigger(&hindsight.MentalModelTrigger{
|
||||
RefreshAfterConsolidation: &refreshAfter,
|
||||
}),
|
||||
}).Execute()
|
||||
|
||||
fmt.Printf("Updated name: %s\n", updated.GetName())
|
||||
// [/docs:update-mental-model]
|
||||
|
||||
// [docs:get-mental-model-history]
|
||||
// Get the change history of a mental model
|
||||
history, _, _ := client.MentalModelsAPI.GetMentalModelHistory(ctx, mmBankID, mentalModelID).Execute()
|
||||
|
||||
if entries, ok := history.([]interface{}); ok {
|
||||
for _, entry := range entries {
|
||||
if e, ok := entry.(map[string]interface{}); ok {
|
||||
fmt.Printf("Changed at: %v\n", e["changed_at"])
|
||||
fmt.Printf("Previous content: %v\n", e["previous_content"])
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/docs:get-mental-model-history]
|
||||
|
||||
// [docs:delete-mental-model]
|
||||
// Delete a mental model
|
||||
client.MentalModelsAPI.DeleteMentalModel(ctx, mmBankID, mentalModelID).Execute()
|
||||
// [/docs:delete-mental-model]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
cleanupMentalModels(client, ctx, apiURL)
|
||||
|
||||
fmt.Println("mental-models.go: All examples passed")
|
||||
}
|
||||
|
||||
func cleanupMentalModels(client *hindsight.APIClient, ctx context.Context, apiURL string) {
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, mmBankID), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mental Models API examples for Hindsight (Node.js)
|
||||
* Run: node examples/api/mental-models.mjs
|
||||
*/
|
||||
import { HindsightClient } from '@vectorize-io/hindsight-client';
|
||||
|
||||
const HINDSIGHT_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
|
||||
const BANK_ID = 'mental-models-demo-bank';
|
||||
|
||||
// =============================================================================
|
||||
// Setup (not shown in docs)
|
||||
// =============================================================================
|
||||
const client = new HindsightClient({ baseUrl: HINDSIGHT_URL });
|
||||
await client.createBank(BANK_ID, { name: 'Mental Models Demo' });
|
||||
await client.retain(BANK_ID, 'The team prefers async communication via Slack');
|
||||
await client.retain(BANK_ID, 'For urgent issues, use the #incidents channel');
|
||||
await client.retain(BANK_ID, 'Weekly syncs happen every Monday at 10am');
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:create-mental-model]
|
||||
// Create a mental model (runs reflect in background)
|
||||
const result = await client.createMentalModel(
|
||||
BANK_ID,
|
||||
'Team Communication Preferences',
|
||||
'How does the team prefer to communicate?',
|
||||
{ tags: ['team', 'communication'] },
|
||||
);
|
||||
|
||||
// Returns an operation_id — check operations endpoint for completion
|
||||
console.log(`Operation ID: ${result.operation_id}`);
|
||||
// [/docs:create-mental-model]
|
||||
|
||||
// [docs:create-mental-model-with-id]
|
||||
// Create a mental model with a specific custom ID
|
||||
const resultWithId = await client.createMentalModel(
|
||||
BANK_ID,
|
||||
'Communication Policy',
|
||||
"What are the team's communication guidelines?",
|
||||
{ id: 'communication-policy' },
|
||||
);
|
||||
|
||||
console.log(`Created with custom ID: ${resultWithId.operation_id}`);
|
||||
// [/docs:create-mental-model-with-id]
|
||||
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// [docs:create-mental-model-with-trigger]
|
||||
// Create a mental model with automatic refresh enabled
|
||||
const result2 = await client.createMentalModel(
|
||||
BANK_ID,
|
||||
'Project Status',
|
||||
'What is the current project status?',
|
||||
{ trigger: { refreshAfterConsolidation: true } },
|
||||
);
|
||||
|
||||
// This mental model will automatically refresh when observations are updated
|
||||
console.log(`Operation ID: ${result2.operation_id}`);
|
||||
// [/docs:create-mental-model-with-trigger]
|
||||
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// [docs:list-mental-models]
|
||||
// List all mental models in a bank
|
||||
const mentalModels = await client.listMentalModels(BANK_ID);
|
||||
|
||||
for (const mm of mentalModels.items) {
|
||||
console.log(`- ${mm.name}: ${mm.source_query}`);
|
||||
}
|
||||
// [/docs:list-mental-models]
|
||||
|
||||
const mentalModelId = mentalModels.items[0]?.id;
|
||||
if (!mentalModelId) {
|
||||
console.log('mental-models.mjs: All examples passed (no mental models created yet)');
|
||||
await fetch(`${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}`, { method: 'DELETE' });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// [docs:get-mental-model]
|
||||
// Get a specific mental model
|
||||
const mentalModel = await client.getMentalModel(BANK_ID, mentalModelId);
|
||||
|
||||
console.log(`Name: ${mentalModel.name}`);
|
||||
console.log(`Content: ${mentalModel.content}`);
|
||||
console.log(`Last refreshed: ${mentalModel.last_refreshed_at}`);
|
||||
// [/docs:get-mental-model]
|
||||
|
||||
// [docs:refresh-mental-model]
|
||||
// Refresh a mental model to update with current knowledge
|
||||
const refreshResult = await client.refreshMentalModel(BANK_ID, mentalModelId);
|
||||
|
||||
console.log(`Refresh operation ID: ${refreshResult.operation_id}`);
|
||||
// [/docs:refresh-mental-model]
|
||||
|
||||
// [docs:update-mental-model]
|
||||
// Update a mental model's metadata
|
||||
const updated = await client.updateMentalModel(BANK_ID, mentalModelId, {
|
||||
name: 'Updated Team Communication Preferences',
|
||||
trigger: { refresh_after_consolidation: true },
|
||||
});
|
||||
|
||||
console.log(`Updated name: ${updated.name}`);
|
||||
// [/docs:update-mental-model]
|
||||
|
||||
// [docs:get-mental-model-history]
|
||||
// Get the change history of a mental model
|
||||
const history = await client.getMentalModelHistory(BANK_ID, mentalModelId);
|
||||
|
||||
for (const entry of history) {
|
||||
console.log(`Changed at: ${entry.changed_at}`);
|
||||
console.log(`Previous content: ${entry.previous_content}`);
|
||||
}
|
||||
// [/docs:get-mental-model-history]
|
||||
|
||||
// [docs:delete-mental-model]
|
||||
// Delete a mental model
|
||||
await client.deleteMentalModel(BANK_ID, mentalModelId);
|
||||
// [/docs:delete-mental-model]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
await client.deleteBank(BANK_ID);
|
||||
|
||||
console.log('mental-models.mjs: All examples passed');
|
||||
@@ -42,6 +42,18 @@ result = client.create_mental_model(
|
||||
print(f"Operation ID: {result.operation_id}")
|
||||
# [/docs:create-mental-model]
|
||||
|
||||
# [docs:create-mental-model-with-id]
|
||||
# Create a mental model with a specific custom ID
|
||||
result_with_id = client.create_mental_model(
|
||||
bank_id=BANK_ID,
|
||||
name="Communication Policy",
|
||||
source_query="What are the team's communication guidelines?",
|
||||
id="communication-policy"
|
||||
)
|
||||
|
||||
print(f"Created with custom ID: {result_with_id.operation_id}")
|
||||
# [/docs:create-mental-model-with-id]
|
||||
|
||||
# Wait for the mental model to be created
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Mental Models API examples for Hindsight CLI
|
||||
# Run: bash examples/api/mental-models.sh
|
||||
|
||||
set -e
|
||||
|
||||
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
|
||||
BANK_ID="mental-models-demo-bank"
|
||||
|
||||
# =============================================================================
|
||||
# Setup (not shown in docs)
|
||||
# =============================================================================
|
||||
hindsight bank create "$BANK_ID" --name "Mental Models Demo"
|
||||
hindsight memory retain "$BANK_ID" "The team prefers async communication via Slack"
|
||||
hindsight memory retain "$BANK_ID" "For urgent issues, use the #incidents channel"
|
||||
hindsight memory retain "$BANK_ID" "Weekly syncs happen every Monday at 10am"
|
||||
sleep 2
|
||||
|
||||
# =============================================================================
|
||||
# Doc Examples
|
||||
# =============================================================================
|
||||
|
||||
# [docs:create-mental-model]
|
||||
# Create a mental model (runs reflect in background)
|
||||
hindsight mental-model create "$BANK_ID" \
|
||||
"Team Communication Preferences" \
|
||||
"How does the team prefer to communicate?"
|
||||
# [/docs:create-mental-model]
|
||||
|
||||
# [docs:create-mental-model-with-id]
|
||||
# Create a mental model with a specific custom ID
|
||||
hindsight mental-model create "$BANK_ID" \
|
||||
"Communication Policy" \
|
||||
"What are the team's communication guidelines?" \
|
||||
--id communication-policy
|
||||
# [/docs:create-mental-model-with-id]
|
||||
|
||||
sleep 5
|
||||
|
||||
# [docs:create-mental-model-with-trigger]
|
||||
# Create a mental model and get its ID for subsequent operations
|
||||
hindsight mental-model create "$BANK_ID" \
|
||||
"Project Status" \
|
||||
"What is the current project status?"
|
||||
# [/docs:create-mental-model-with-trigger]
|
||||
|
||||
sleep 5
|
||||
|
||||
# [docs:list-mental-models]
|
||||
# List all mental models in a bank
|
||||
hindsight mental-model list "$BANK_ID"
|
||||
# [/docs:list-mental-models]
|
||||
|
||||
# Get the first mental model ID for subsequent examples
|
||||
MENTAL_MODEL_ID=$(hindsight mental-model list "$BANK_ID" -o json | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$MENTAL_MODEL_ID" ]; then
|
||||
# [docs:get-mental-model]
|
||||
# Get a specific mental model
|
||||
hindsight mental-model get "$BANK_ID" "$MENTAL_MODEL_ID"
|
||||
# [/docs:get-mental-model]
|
||||
|
||||
# [docs:refresh-mental-model]
|
||||
# Refresh a mental model to update with current knowledge
|
||||
hindsight mental-model refresh "$BANK_ID" "$MENTAL_MODEL_ID"
|
||||
# [/docs:refresh-mental-model]
|
||||
|
||||
# [docs:update-mental-model]
|
||||
# Update a mental model's metadata
|
||||
hindsight mental-model update "$BANK_ID" "$MENTAL_MODEL_ID" \
|
||||
--name "Updated Team Communication Preferences"
|
||||
# [/docs:update-mental-model]
|
||||
|
||||
# [docs:get-mental-model-history]
|
||||
# Get the change history of a mental model
|
||||
hindsight mental-model history "$BANK_ID" "$MENTAL_MODEL_ID"
|
||||
# [/docs:get-mental-model-history]
|
||||
|
||||
# [docs:delete-mental-model]
|
||||
# Delete a mental model
|
||||
hindsight mental-model delete "$BANK_ID" "$MENTAL_MODEL_ID" -y
|
||||
# [/docs:delete-mental-model]
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup (not shown in docs)
|
||||
# =============================================================================
|
||||
curl -s -X DELETE "${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}" > /dev/null
|
||||
|
||||
echo "mental-models.sh: All examples passed"
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Setup (not shown in docs)
|
||||
// =============================================================================
|
||||
for _, content := range []string{
|
||||
"Alice works at Google as a software engineer",
|
||||
"Alice loves hiking on weekends",
|
||||
"Bob is a data scientist who works with Alice",
|
||||
} {
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{{Content: content}},
|
||||
}).Execute()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:recall-basic]
|
||||
response, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What does Alice do?",
|
||||
}).Execute()
|
||||
|
||||
// response.Results is a slice of RecallResult, each with:
|
||||
// - Id: fact ID
|
||||
// - Text: the extracted fact
|
||||
// - Type: "world", "experience", or "observation"
|
||||
// - Context: context label set during retain
|
||||
// - Tags: []string of tags
|
||||
// - Entities: []string of entity names linked to this fact
|
||||
// - OccurredStart: ISO datetime of when the event started
|
||||
// - OccurredEnd: ISO datetime of when the event ended
|
||||
// - MentionedAt: ISO datetime of when the fact was retained
|
||||
// - DocumentId: document this fact belongs to
|
||||
for _, r := range response.GetResults() {
|
||||
fmt.Println(r.GetText())
|
||||
}
|
||||
// [/docs:recall-basic]
|
||||
|
||||
// [docs:recall-with-options]
|
||||
budgetHigh := hindsight.HIGH
|
||||
maxTokens := int32(8000)
|
||||
traceTrue := true
|
||||
detailedResponse, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What does Alice do?",
|
||||
Types: []string{"world", "experience"},
|
||||
Budget: &budgetHigh,
|
||||
MaxTokens: &maxTokens,
|
||||
Trace: &traceTrue,
|
||||
}).Execute()
|
||||
|
||||
for _, r := range detailedResponse.GetResults() {
|
||||
fmt.Println("-", r.GetText())
|
||||
}
|
||||
// [/docs:recall-with-options]
|
||||
|
||||
// [docs:recall-world-only]
|
||||
// Only world facts (objective information)
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "Where does Alice work?",
|
||||
Types: []string{"world"},
|
||||
}).Execute()
|
||||
// [/docs:recall-world-only]
|
||||
|
||||
// [docs:recall-experience-only]
|
||||
// Only experience (conversations and events)
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What have I recommended?",
|
||||
Types: []string{"experience"},
|
||||
}).Execute()
|
||||
// [/docs:recall-experience-only]
|
||||
|
||||
// [docs:recall-observations-only]
|
||||
// Only observations (consolidated knowledge)
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What patterns have I learned?",
|
||||
Types: []string{"observation"},
|
||||
}).Execute()
|
||||
// [/docs:recall-observations-only]
|
||||
|
||||
// [docs:recall-source-facts]
|
||||
// Recall observations and include their source facts
|
||||
maxSFTokens := int32(4096)
|
||||
sfOpts := hindsight.SourceFactsIncludeOptions{MaxTokens: &maxSFTokens}
|
||||
obsResponse, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What patterns have I learned about Alice?",
|
||||
Types: []string{"observation"},
|
||||
Include: &hindsight.IncludeOptions{
|
||||
SourceFacts: *hindsight.NewNullableSourceFactsIncludeOptions(&sfOpts),
|
||||
},
|
||||
}).Execute()
|
||||
|
||||
for _, obs := range obsResponse.GetResults() {
|
||||
fmt.Printf("Observation: %s\n", obs.GetText())
|
||||
for _, factID := range obs.GetSourceFactIds() {
|
||||
if fact, ok := obsResponse.GetSourceFacts()[factID]; ok {
|
||||
fmt.Printf(" - [%s] %s\n", fact.GetType(), fact.GetText())
|
||||
}
|
||||
}
|
||||
}
|
||||
// [/docs:recall-source-facts]
|
||||
|
||||
// [docs:recall-budget-levels]
|
||||
budgetLow := hindsight.LOW
|
||||
// Quick lookup
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "Alice's email",
|
||||
Budget: &budgetLow,
|
||||
}).Execute()
|
||||
|
||||
// Deep exploration
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "How are Alice and Bob connected?",
|
||||
Budget: &budgetHigh,
|
||||
}).Execute()
|
||||
// [/docs:recall-budget-levels]
|
||||
|
||||
// [docs:recall-token-budget]
|
||||
// Fill up to 4K tokens of context with relevant memories
|
||||
mt4k := int32(4096)
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What do I know about Alice?",
|
||||
MaxTokens: &mt4k,
|
||||
}).Execute()
|
||||
|
||||
// Smaller budget for quick lookups
|
||||
mt500 := int32(500)
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "Alice's email",
|
||||
MaxTokens: &mt500,
|
||||
}).Execute()
|
||||
// [/docs:recall-token-budget]
|
||||
|
||||
// [docs:recall-with-tags]
|
||||
// Filter recall to only memories tagged for a specific user
|
||||
tagsMatch := "any"
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What feedback did the user give?",
|
||||
Tags: []string{"user:alice"},
|
||||
TagsMatch: &tagsMatch,
|
||||
}).Execute()
|
||||
// [/docs:recall-with-tags]
|
||||
|
||||
// [docs:recall-tags-strict]
|
||||
// Strict mode: only return memories that have matching tags (exclude untagged)
|
||||
tagsMatchStrict := "any_strict"
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What did the user say?",
|
||||
Tags: []string{"user:alice"},
|
||||
TagsMatch: &tagsMatchStrict,
|
||||
}).Execute()
|
||||
// [/docs:recall-tags-strict]
|
||||
|
||||
// [docs:recall-tags-all]
|
||||
// AND matching: require ALL specified tags to be present
|
||||
tagsMatchAll := "all_strict"
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "What bugs were reported?",
|
||||
Tags: []string{"user:alice", "bug-report"},
|
||||
TagsMatch: &tagsMatchAll,
|
||||
}).Execute()
|
||||
// [/docs:recall-tags-all]
|
||||
|
||||
// [docs:recall-tags-all-mode]
|
||||
// AND matching, includes untagged memories
|
||||
tagsMatchAllMode := "all"
|
||||
client.MemoryAPI.RecallMemories(ctx, "my-bank").
|
||||
RecallRequest(hindsight.RecallRequest{
|
||||
Query: "communication tools",
|
||||
Tags: []string{"user:alice", "team"},
|
||||
TagsMatch: &tagsMatchAllMode,
|
||||
}).Execute()
|
||||
// [/docs:recall-tags-all-mode]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
|
||||
fmt.Println("recall.go: All examples passed")
|
||||
}
|
||||
@@ -61,6 +61,88 @@ for (const r of detailedResponse.results) {
|
||||
// [/docs:recall-with-options]
|
||||
|
||||
|
||||
// [docs:recall-world-only]
|
||||
await client.recall('my-bank', 'query', { types: ['world'] });
|
||||
// [/docs:recall-world-only]
|
||||
|
||||
|
||||
// [docs:recall-experience-only]
|
||||
await client.recall('my-bank', 'query', { types: ['experience'] });
|
||||
// [/docs:recall-experience-only]
|
||||
|
||||
|
||||
// [docs:recall-observations-only]
|
||||
await client.recall('my-bank', 'query', { types: ['observation'] });
|
||||
// [/docs:recall-observations-only]
|
||||
|
||||
|
||||
// [docs:recall-token-budget]
|
||||
// Fill up to 4K tokens of context with relevant memories
|
||||
await client.recall('my-bank', 'What do I know about Alice?', { maxTokens: 4096 });
|
||||
|
||||
// Smaller budget for quick lookups
|
||||
await client.recall('my-bank', "Alice's email", { maxTokens: 500 });
|
||||
// [/docs:recall-token-budget]
|
||||
|
||||
|
||||
// [docs:recall-with-tags]
|
||||
// Filter recall to only memories tagged for a specific user
|
||||
await client.recall('my-bank', 'What feedback did the user give?', {
|
||||
tags: ['user:alice']
|
||||
});
|
||||
// [/docs:recall-with-tags]
|
||||
|
||||
|
||||
// [docs:recall-tags-strict]
|
||||
// Strict: only memories that have matching tags (excludes untagged)
|
||||
await client.recall('my-bank', 'What did the user say?', {
|
||||
tags: ['user:alice'],
|
||||
tagsMatch: 'any_strict'
|
||||
});
|
||||
// [/docs:recall-tags-strict]
|
||||
|
||||
|
||||
// [docs:recall-tags-all]
|
||||
// AND matching: require ALL specified tags to be present
|
||||
await client.recall('my-bank', 'What bugs were reported?', {
|
||||
tags: ['user:alice', 'bug-report'],
|
||||
tagsMatch: 'all_strict'
|
||||
});
|
||||
// [/docs:recall-tags-all]
|
||||
|
||||
|
||||
// [docs:recall-tags-any]
|
||||
await client.recall('my-bank', 'communication preferences', {
|
||||
tags: ['user:alice'],
|
||||
tagsMatch: 'any'
|
||||
});
|
||||
// [/docs:recall-tags-any]
|
||||
|
||||
|
||||
// [docs:recall-tags-any-strict]
|
||||
await client.recall('my-bank', 'communication preferences', {
|
||||
tags: ['user:alice'],
|
||||
tagsMatch: 'any_strict'
|
||||
});
|
||||
// [/docs:recall-tags-any-strict]
|
||||
|
||||
|
||||
// [docs:recall-tags-all-mode]
|
||||
await client.recall('my-bank', 'communication tools', {
|
||||
tags: ['user:alice', 'team'],
|
||||
tagsMatch: 'all'
|
||||
});
|
||||
// [/docs:recall-tags-all-mode]
|
||||
|
||||
|
||||
// [docs:recall-tags-all-strict]
|
||||
await client.recall('my-bank', 'communication tools', {
|
||||
tags: ['user:alice', 'team'],
|
||||
tagsMatch: 'all_strict'
|
||||
});
|
||||
// [/docs:recall-tags-all-strict]
|
||||
|
||||
|
||||
// [docs:recall-source-facts]
|
||||
// Recall observations and include their source facts
|
||||
const obsResponse = await client.recall('my-bank', 'What patterns have I learned about Alice?', {
|
||||
|
||||
@@ -38,6 +38,76 @@ hindsight memory recall my-bank "query" --trace
|
||||
# [/docs:recall-trace]
|
||||
|
||||
|
||||
# [docs:recall-budget-levels]
|
||||
# Quick lookup
|
||||
hindsight memory recall my-bank "Alice's email" --budget low
|
||||
|
||||
# Deep exploration
|
||||
hindsight memory recall my-bank "How are Alice and Bob connected?" --budget high
|
||||
# [/docs:recall-budget-levels]
|
||||
|
||||
|
||||
# [docs:recall-token-budget]
|
||||
# Fill up to 4K tokens of context with relevant memories
|
||||
hindsight memory recall my-bank "What do I know about Alice?" --max-tokens 4096
|
||||
|
||||
# Smaller budget for quick lookups
|
||||
hindsight memory recall my-bank "Alice's email" --max-tokens 500
|
||||
# [/docs:recall-token-budget]
|
||||
|
||||
|
||||
# [docs:recall-source-facts]
|
||||
# Recall observations with source facts
|
||||
hindsight memory recall my-bank "What patterns have I learned about Alice?" \
|
||||
--fact-type observation
|
||||
# [/docs:recall-source-facts]
|
||||
|
||||
|
||||
# [docs:recall-with-tags]
|
||||
# Filter recall to only memories tagged for a specific user
|
||||
hindsight memory recall my-bank "What feedback did the user give?" \
|
||||
--tags "user:alice"
|
||||
# [/docs:recall-with-tags]
|
||||
|
||||
|
||||
# [docs:recall-tags-strict]
|
||||
# Strict: only memories that have matching tags (excludes untagged)
|
||||
hindsight memory recall my-bank "What did the user say?" \
|
||||
--tags "user:alice" --tags-match any_strict
|
||||
# [/docs:recall-tags-strict]
|
||||
|
||||
|
||||
# [docs:recall-tags-all]
|
||||
# AND matching: require ALL specified tags to be present
|
||||
hindsight memory recall my-bank "What bugs were reported?" \
|
||||
--tags "user:alice,bug-report" --tags-match all_strict
|
||||
# [/docs:recall-tags-all]
|
||||
|
||||
|
||||
# [docs:recall-tags-any]
|
||||
hindsight memory recall my-bank "communication preferences" \
|
||||
--tags "user:alice" --tags-match any
|
||||
# [/docs:recall-tags-any]
|
||||
|
||||
|
||||
# [docs:recall-tags-any-strict]
|
||||
hindsight memory recall my-bank "communication preferences" \
|
||||
--tags "user:alice" --tags-match any_strict
|
||||
# [/docs:recall-tags-any-strict]
|
||||
|
||||
|
||||
# [docs:recall-tags-all-mode]
|
||||
hindsight memory recall my-bank "communication tools" \
|
||||
--tags "user:alice,team" --tags-match all
|
||||
# [/docs:recall-tags-all-mode]
|
||||
|
||||
|
||||
# [docs:recall-tags-all-strict]
|
||||
hindsight memory recall my-bank "communication tools" \
|
||||
--tags "user:alice,team" --tags-match all_strict
|
||||
# [/docs:recall-tags-all-strict]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup (not shown in docs)
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Setup (not shown in docs)
|
||||
// =============================================================================
|
||||
for _, content := range []string{
|
||||
"Alice works at Google as a software engineer",
|
||||
"Alice has been working there for 5 years",
|
||||
"Alice recently got promoted to senior engineer",
|
||||
} {
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{{Content: content}},
|
||||
}).Execute()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:reflect-basic]
|
||||
client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "What should I know about Alice?",
|
||||
}).Execute()
|
||||
// [/docs:reflect-basic]
|
||||
|
||||
// [docs:reflect-with-params]
|
||||
budgetMid := hindsight.MID
|
||||
client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "We're considering a hybrid work policy. What do you think about remote work?",
|
||||
Budget: &budgetMid,
|
||||
}).Execute()
|
||||
// [/docs:reflect-with-params]
|
||||
|
||||
// [docs:reflect-with-context]
|
||||
// Context is passed to the LLM to help it understand the situation
|
||||
ctxText := "We're in a budget review meeting discussing Q4 spending"
|
||||
client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "What do you think about the proposal?",
|
||||
Context: *hindsight.NewNullableString(&ctxText),
|
||||
}).Execute()
|
||||
// [/docs:reflect-with-context]
|
||||
|
||||
// [docs:reflect-disposition]
|
||||
// Create a bank with specific disposition
|
||||
skepticism := int32(5)
|
||||
literalism := int32(4)
|
||||
empathy := int32(2)
|
||||
mission := "I am a risk-aware financial advisor"
|
||||
client.BanksAPI.CreateOrUpdateBank(ctx, "cautious-advisor").
|
||||
CreateBankRequest(hindsight.CreateBankRequest{
|
||||
Name: *hindsight.NewNullableString(hindsight.PtrString("Cautious Advisor")),
|
||||
ReflectMission: *hindsight.NewNullableString(&mission),
|
||||
DispositionSkepticism: *hindsight.NewNullableInt32(&skepticism),
|
||||
DispositionLiteralism: *hindsight.NewNullableInt32(&literalism),
|
||||
DispositionEmpathy: *hindsight.NewNullableInt32(&empathy),
|
||||
}).Execute()
|
||||
|
||||
// Reflect responses will reflect this disposition
|
||||
client.MemoryAPI.Reflect(ctx, "cautious-advisor").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "Should I invest in crypto?",
|
||||
}).Execute()
|
||||
// Response will likely emphasize risks and caution
|
||||
// [/docs:reflect-disposition]
|
||||
|
||||
// [docs:reflect-sources]
|
||||
// include.facts enables the based_on field in the response
|
||||
sourcesResponse, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "Tell me about Alice",
|
||||
Include: &hindsight.ReflectIncludeOptions{
|
||||
Facts: map[string]interface{}{}, // empty map enables fact inclusion
|
||||
},
|
||||
}).Execute()
|
||||
|
||||
fmt.Println("Response:", sourcesResponse.GetText())
|
||||
fmt.Println("\nBased on:")
|
||||
if basedOn := sourcesResponse.GetBasedOn(); basedOn.Memories != nil {
|
||||
for _, fact := range basedOn.GetMemories() {
|
||||
fmt.Printf(" - [%s] %s\n", fact.GetType(), fact.GetText())
|
||||
}
|
||||
}
|
||||
// [/docs:reflect-sources]
|
||||
|
||||
// [docs:reflect-with-tags]
|
||||
// Filter reflection to only consider memories for a specific user
|
||||
tagsMatch := "any_strict"
|
||||
client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "What does this user think about our product?",
|
||||
Tags: []string{"user:alice"},
|
||||
TagsMatch: &tagsMatch,
|
||||
}).Execute()
|
||||
// [/docs:reflect-with-tags]
|
||||
|
||||
// [docs:reflect-structured-output]
|
||||
// Define JSON schema for structured output
|
||||
responseSchema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"recommendation": map[string]interface{}{"type": "string"},
|
||||
"confidence": map[string]interface{}{"type": "string", "enum": []string{"low", "medium", "high"}},
|
||||
"key_factors": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"risks": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"required": []string{"recommendation", "confidence", "key_factors"},
|
||||
}
|
||||
|
||||
structuredResponse, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
|
||||
ReflectRequest(hindsight.ReflectRequest{
|
||||
Query: "Should we hire Alice for the ML team lead position?",
|
||||
ResponseSchema: responseSchema,
|
||||
}).Execute()
|
||||
|
||||
// Access structured output
|
||||
if out := structuredResponse.GetStructuredOutput(); out != nil {
|
||||
fmt.Println("Recommendation:", out["recommendation"])
|
||||
fmt.Println("Key factors:", out["key_factors"])
|
||||
}
|
||||
// [/docs:reflect-structured-output]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
for _, bankID := range []string{"my-bank", "cautious-advisor"} {
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
fmt.Println("reflect.go: All examples passed")
|
||||
}
|
||||
@@ -60,16 +60,27 @@ const advisorResponse = await client.reflect('cautious-advisor', 'Should I inves
|
||||
|
||||
|
||||
// [docs:reflect-sources]
|
||||
const sourcesResponse = await client.reflect('my-bank', 'Tell me about Alice');
|
||||
const sourcesResponse = await client.reflect('my-bank', 'Tell me about Alice', {
|
||||
includeFacts: true
|
||||
});
|
||||
|
||||
console.log('Response:', sourcesResponse.text);
|
||||
console.log('\nBased on:');
|
||||
for (const fact of sourcesResponse.based_on || []) {
|
||||
for (const fact of (sourcesResponse.based_on?.memories || [])) {
|
||||
console.log(` - [${fact.type}] ${fact.text}`);
|
||||
}
|
||||
// [/docs:reflect-sources]
|
||||
|
||||
|
||||
// [docs:reflect-with-tags]
|
||||
// Filter reflect to only use memories tagged for a specific user
|
||||
await client.reflect('my-bank', 'What feedback did the user give?', {
|
||||
tags: ['user:alice'],
|
||||
tagsMatch: 'any_strict'
|
||||
});
|
||||
// [/docs:reflect-with-tags]
|
||||
|
||||
|
||||
// [docs:reflect-structured-output]
|
||||
// Define JSON schema directly
|
||||
const responseSchema = {
|
||||
|
||||
@@ -26,9 +26,29 @@ hindsight memory reflect my-bank "Should I learn Python?" --context "career advi
|
||||
# [/docs:reflect-with-context]
|
||||
|
||||
|
||||
# [docs:reflect-high-budget]
|
||||
hindsight memory reflect my-bank "Summarize my week" --budget high
|
||||
# [/docs:reflect-high-budget]
|
||||
# [docs:reflect-with-params]
|
||||
hindsight memory reflect my-bank "Summarize my week" --budget high --max-tokens 8192
|
||||
# [/docs:reflect-with-params]
|
||||
|
||||
|
||||
# [docs:reflect-disposition]
|
||||
hindsight bank set-config my-bank \
|
||||
--disposition-skepticism 5 \
|
||||
--disposition-literalism 4 \
|
||||
--disposition-empathy 2
|
||||
hindsight memory reflect my-bank "Should I invest in crypto?"
|
||||
# [/docs:reflect-disposition]
|
||||
|
||||
|
||||
# [docs:reflect-sources]
|
||||
hindsight memory reflect my-bank "Tell me about Alice" --include-facts
|
||||
# [/docs:reflect-sources]
|
||||
|
||||
|
||||
# [docs:reflect-with-tags]
|
||||
hindsight memory reflect my-bank "What feedback did the user give?" \
|
||||
--tags "user:alice" --tags-match any_strict
|
||||
# [/docs:reflect-with-tags]
|
||||
|
||||
|
||||
# [docs:reflect-structured-output]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
apiURL := os.Getenv("HINDSIGHT_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "http://localhost:8888"
|
||||
}
|
||||
|
||||
cfg := hindsight.NewConfiguration()
|
||||
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
|
||||
client := hindsight.NewAPIClient(cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// =============================================================================
|
||||
// Doc Examples
|
||||
// =============================================================================
|
||||
|
||||
// [docs:retain-basic]
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{Content: "Alice works at Google as a software engineer"},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:retain-basic]
|
||||
|
||||
// [docs:retain-conversation]
|
||||
// Retain an entire conversation as a single document.
|
||||
conversation := "Alice (2024-03-15T09:00:00Z): Hi Bob! Did you end up going to the doctor last week?\n" +
|
||||
"Bob (2024-03-15T09:01:00Z): Yes, finally. Turns out I have a mild peanut allergy.\n" +
|
||||
"Alice (2024-03-15T09:02:00Z): Oh no! Are you okay?\n" +
|
||||
"Bob (2024-03-15T09:03:00Z): Yeah, nothing serious. Just need to carry an antihistamine.\n" +
|
||||
"Alice (2024-03-15T09:04:00Z): Good to know. We'll avoid peanuts at the team lunch."
|
||||
|
||||
docID := "chat-2024-03-15-alice-bob"
|
||||
context_ := "team chat"
|
||||
ts := "2024-03-15T09:04:00Z"
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{
|
||||
Content: conversation,
|
||||
Context: *hindsight.NewNullableString(&context_),
|
||||
DocumentId: *hindsight.NewNullableString(&docID),
|
||||
Timestamp: *hindsight.NewNullableTimestamp(&hindsight.Timestamp{
|
||||
String: &ts,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:retain-conversation]
|
||||
|
||||
// [docs:retain-with-context]
|
||||
ctxLabel := "career update"
|
||||
ts2 := "2024-03-15T10:00:00Z"
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{
|
||||
Content: "Alice got promoted to senior engineer",
|
||||
Context: *hindsight.NewNullableString(&ctxLabel),
|
||||
Timestamp: *hindsight.NewNullableTimestamp(&hindsight.Timestamp{
|
||||
String: &ts2,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:retain-with-context]
|
||||
|
||||
// [docs:retain-batch]
|
||||
doc1 := "conversation_001_msg_1"
|
||||
doc2 := "conversation_001_msg_2"
|
||||
doc3 := "conversation_001_msg_3"
|
||||
ctx1 := "career"
|
||||
ctx2 := "relationship"
|
||||
client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{Content: "Alice works at Google", Context: *hindsight.NewNullableString(&ctx1), DocumentId: *hindsight.NewNullableString(&doc1)},
|
||||
{Content: "Bob is a data scientist at Meta", Context: *hindsight.NewNullableString(&ctx1), DocumentId: *hindsight.NewNullableString(&doc2)},
|
||||
{Content: "Alice and Bob are friends", Context: *hindsight.NewNullableString(&ctx2), DocumentId: *hindsight.NewNullableString(&doc3)},
|
||||
},
|
||||
}).Execute()
|
||||
// [/docs:retain-batch]
|
||||
|
||||
// [docs:retain-async]
|
||||
// Start async ingestion (returns immediately)
|
||||
asyncTrue := true
|
||||
largeDoc1 := "large-doc-1"
|
||||
largeDoc2 := "large-doc-2"
|
||||
retainResp, _, _ := client.MemoryAPI.RetainMemories(ctx, "my-bank").
|
||||
RetainRequest(hindsight.RetainRequest{
|
||||
Items: []hindsight.MemoryItem{
|
||||
{Content: "Large batch item 1", DocumentId: *hindsight.NewNullableString(&largeDoc1)},
|
||||
{Content: "Large batch item 2", DocumentId: *hindsight.NewNullableString(&largeDoc2)},
|
||||
},
|
||||
Async: &asyncTrue,
|
||||
}).Execute()
|
||||
|
||||
// Check if it was processed asynchronously
|
||||
fmt.Println("Async:", retainResp.GetAsync())
|
||||
// [/docs:retain-async]
|
||||
|
||||
// [docs:retain-files]
|
||||
// Open a file and upload it — Hindsight converts it to text and extracts memories.
|
||||
// Supports: PDF, DOCX, PPTX, XLSX, images (OCR), audio (transcription), and text formats.
|
||||
f, err := os.Open("../../hindsight-docs/examples/api/sample.pdf")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileResp, _, _ := client.FilesAPI.FileRetain(ctx, "my-bank").
|
||||
Files([]*os.File{f}).
|
||||
Request(`{"files_metadata": [{"context": "quarterly report"}]}`).
|
||||
Execute()
|
||||
fmt.Println("Operation IDs:", fileResp.GetOperationIds()) // Track processing via the operations endpoint
|
||||
// [/docs:retain-files]
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
|
||||
http.DefaultClient.Do(req)
|
||||
|
||||
fmt.Println("retain.go: All examples passed")
|
||||
}
|
||||
@@ -85,6 +85,21 @@ console.log(result.operation_ids); // Track processing via the operations endpo
|
||||
// [/docs:retain-files]
|
||||
|
||||
|
||||
// [docs:retain-files-batch]
|
||||
// Upload multiple files with per-file metadata (up to 10 files per request)
|
||||
const batchResult = await client.retainFiles('my-bank', [
|
||||
new File([pdfBytes], 'report.pdf'),
|
||||
new File([pdfBytes], 'notes.pdf'),
|
||||
], {
|
||||
filesMetadata: [
|
||||
{ context: 'quarterly report', document_id: 'q1-report', tags: ['project:alpha'] },
|
||||
{ context: 'meeting notes', document_id: 'q1-notes', tags: ['project:alpha'] },
|
||||
]
|
||||
});
|
||||
console.log(batchResult.operation_ids); // One operation ID per file
|
||||
// [/docs:retain-files-batch]
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup (not shown in docs)
|
||||
// =============================================================================
|
||||
|
||||
@@ -25,12 +25,37 @@ hindsight memory retain my-bank "Alice works at Google as a software engineer"
|
||||
# [/docs:retain-basic]
|
||||
|
||||
|
||||
# [docs:retain-conversation]
|
||||
# Retain an entire conversation as a single document.
|
||||
CONVERSATION="Alice (2024-03-15T09:00:00Z): Hi Bob! Did you end up going to the doctor last week?
|
||||
Bob (2024-03-15T09:01:00Z): Yes, finally. Turns out I have a mild peanut allergy.
|
||||
Alice (2024-03-15T09:02:00Z): Oh no! Are you okay?
|
||||
Bob (2024-03-15T09:03:00Z): Yeah, nothing serious. Just need to carry an antihistamine.
|
||||
Alice (2024-03-15T09:04:00Z): Good to know. We'll avoid peanuts at the team lunch."
|
||||
|
||||
hindsight memory retain my-bank "$CONVERSATION" \
|
||||
--context "team chat" \
|
||||
--doc-id "chat-2024-03-15-alice-bob"
|
||||
# [/docs:retain-conversation]
|
||||
|
||||
|
||||
# [docs:retain-with-context]
|
||||
hindsight memory retain my-bank "Alice got promoted" \
|
||||
--context "career update"
|
||||
# [/docs:retain-with-context]
|
||||
|
||||
|
||||
# [docs:retain-batch]
|
||||
# Batch ingestion via individual retain calls (CLI processes items one at a time)
|
||||
hindsight memory retain my-bank "Alice works at Google" \
|
||||
--context "career" --doc-id "conversation_001_msg_1"
|
||||
hindsight memory retain my-bank "Bob is a data scientist at Meta" \
|
||||
--context "career" --doc-id "conversation_001_msg_2"
|
||||
hindsight memory retain my-bank "Alice and Bob are friends" \
|
||||
--context "relationship" --doc-id "conversation_001_msg_3"
|
||||
# [/docs:retain-batch]
|
||||
|
||||
|
||||
# [docs:retain-async]
|
||||
hindsight memory retain my-bank "Meeting notes" --async
|
||||
# [/docs:retain-async]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"build": "node scripts/check-code-parity.mjs && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates that every "language" Tabs block in MDX docs has all 4 required variants:
|
||||
* Python, Node.js, CLI, Go.
|
||||
*
|
||||
* A Tabs block is considered a "language" block if it contains at least one TabItem
|
||||
* with value "python", "node", "cli", or "go".
|
||||
*
|
||||
* Run: node scripts/check-code-parity.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const docsRoot = join(__dirname, '..');
|
||||
const REQUIRED_TABS = new Set(['python', 'node', 'cli', 'go']);
|
||||
|
||||
const IGNORED_PATHS = [
|
||||
'node_modules',
|
||||
'build',
|
||||
'.docusaurus',
|
||||
'versioned_docs', // skip versioned docs
|
||||
];
|
||||
|
||||
/**
|
||||
* Recursively find all .mdx files under a directory.
|
||||
*/
|
||||
function findMdxFiles(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (IGNORED_PATHS.includes(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...findMdxFiles(full));
|
||||
} else if (entry.endsWith('.mdx') || entry.endsWith('.md')) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single MDX file and return all violations.
|
||||
* A violation is a Tabs block that has at least one language tab but is missing
|
||||
* one or more of the 4 required language variants.
|
||||
*/
|
||||
function checkFile(filePath) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const violations = [];
|
||||
|
||||
// Split content into Tabs blocks.
|
||||
// Strategy: find <Tabs> ... </Tabs> sections and scan for TabItem values.
|
||||
// We use a simple line-by-line state machine.
|
||||
const lines = content.split('\n');
|
||||
let inTabs = false;
|
||||
let tabsStartLine = -1;
|
||||
let currentTabValues = new Set();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!inTabs) {
|
||||
// Look for opening <Tabs> tag (not <TabItem>)
|
||||
if (/^\s*<Tabs[\s>]/.test(line) && !/<\/Tabs/.test(line)) {
|
||||
inTabs = true;
|
||||
tabsStartLine = i + 1; // 1-indexed
|
||||
currentTabValues = new Set();
|
||||
}
|
||||
} else {
|
||||
// Inside a Tabs block — look for </Tabs> or nested TabItem values
|
||||
if (/^\s*<\/Tabs\s*>/.test(line)) {
|
||||
// End of Tabs block — check if it's a language block
|
||||
const hasLanguageTab = [...currentTabValues].some(v => REQUIRED_TABS.has(v));
|
||||
if (hasLanguageTab) {
|
||||
const missing = [...REQUIRED_TABS].filter(t => !currentTabValues.has(t));
|
||||
if (missing.length > 0) {
|
||||
violations.push({
|
||||
line: tabsStartLine,
|
||||
found: [...currentTabValues].filter(v => REQUIRED_TABS.has(v)),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
inTabs = false;
|
||||
currentTabValues = new Set();
|
||||
} else {
|
||||
// Look for TabItem value attributes
|
||||
// Matches: <TabItem value="python" or <TabItem value='cli'
|
||||
const match = line.match(/TabItem[^>]*value=["']([^"']+)["']/);
|
||||
if (match) {
|
||||
currentTabValues.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mdxFiles = findMdxFiles(docsRoot);
|
||||
let totalViolations = 0;
|
||||
|
||||
for (const filePath of mdxFiles) {
|
||||
const violations = checkFile(filePath);
|
||||
if (violations.length > 0) {
|
||||
const rel = relative(docsRoot, filePath);
|
||||
for (const v of violations) {
|
||||
console.error(
|
||||
`[code-parity] ${rel}:${v.line} — Tabs block missing language tabs: ${v.missing.join(', ')} (found: ${v.found.join(', ')})`
|
||||
);
|
||||
}
|
||||
totalViolations += violations.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalViolations > 0) {
|
||||
console.error(`\n[code-parity] ❌ Found ${totalViolations} Tabs block(s) missing required language variants.`);
|
||||
console.error('[code-parity] Every Tabs block with language tabs must include: python, node, cli, go');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[code-parity] ✅ All ${mdxFiles.length} docs files pass 4-tab parity check.`);
|
||||
}
|
||||
@@ -226,6 +226,18 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Hermes Agent',
|
||||
customProps: { icon: '/img/icons/hermes.png' },
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'sdks/integrations/langgraph',
|
||||
label: 'LangGraph / LangChain',
|
||||
customProps: { icon: '/img/icons/langgraph.png' },
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'sdks/integrations/nemoclaw',
|
||||
label: 'NemoClaw',
|
||||
customProps: { icon: '/img/icons/nemoclaw.png' },
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'sdks/integrations/skills',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user