Compare commits

..
5 Commits
Author SHA1 Message Date
Nicolò Boschi afde5d905a ci: trigger CI run 2026-03-13 14:13:43 +01:00
Nicolò Boschi 8af978a397 feat: reject tags+tag_groups together, add tag_groups integration tests
- Add model_validator to RecallRequest and ReflectRequest that returns 422
  when both `tags` and `tag_groups` are set (mutually exclusive)
- Add 5 integration tests for tag_groups compound filtering:
  * validation: 422 when both fields are set
  * AND filter: two leaf groups (step scope AND user scope)
  * OR compound: user:alice OR user:bob
  * NOT compound: user:alice AND NOT archived
  * Nested: user:alice AND (step:5 OR step:8)
2026-03-13 14:13:43 +01:00
Nicolò Boschi 75356d1fed fix: add tag_groups: None to Rust client test RecallRequest initializer 2026-03-13 14:13:43 +01:00
Nicolò Boschi f94d3c7a9e fix: add tag_groups: None to Rust CLI struct initializers 2026-03-13 14:13:43 +01:00
Nicolò Boschi 3ce4ad2835 feat: add compound tag filtering via tag_groups
Adds tag_groups to RecallRequest and ReflectRequest to express arbitrary
boolean tag predicates: leaf {tags, match}, and/or/not compounds.
Top-level groups are AND-ed. Existing tags/tags_match unchanged.

Examples:
  Step filter AND user scope:
    tag_groups: [{tags: ["step:5","step:8"], match: "any_strict"},
                 {tags: ["user:alice"], match: "all_strict"}]
  Exclusion:
    tag_groups: [{tags: ["user:alice"], match: "all_strict"},
                 {not: {tags: ["archived"], match: "any_strict"}}]

- Recursive SQL builder (build_tag_groups_where_clause) threads through
  all 4 retrieval strategies (semantic/BM25, temporal, graph, MPFP)
- Python-side filter (filter_results_by_tag_groups) for post-traversal
- 22 new unit tests
- OpenAPI spec + all clients regenerated (Rust, Python, TypeScript, Go)
2026-03-13 14:13:43 +01:00
449 changed files with 2009 additions and 33358 deletions
+2 -2
View File
@@ -20,10 +20,10 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: MiniMax configuration (1M context window)
# Example: MiniMax configuration (204K context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.5
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
-99
View File
@@ -1,99 +0,0 @@
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
else
echo "type=typescript" >> $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) ────────────────────
- 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 }}
+215 -9
View File
@@ -21,7 +21,7 @@ jobs:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -46,10 +46,22 @@ 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
# 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
@@ -81,12 +93,30 @@ 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
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -98,7 +128,10 @@ 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/*
retention-days: 1
release-typescript-client:
@@ -150,6 +183,153 @@ 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
@@ -407,7 +587,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
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]
permissions:
contents: write
@@ -419,43 +599,61 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Download Python packages
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: python-packages
path: ./artifacts/python-packages
- name: Download TypeScript client
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v4
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v4
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: control-plane
path: ./artifacts/control-plane
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: rust-cli-hindsight-linux-amd64
path: ./artifacts/rust-cli-linux
- name: Download Rust CLI (macOS Intel)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: rust-cli-hindsight-darwin-amd64
path: ./artifacts/rust-cli-darwin-amd64
- name: Download Rust CLI (macOS ARM)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: rust-cli-hindsight-darwin-arm64
path: ./artifacts/rust-cli-darwin-arm64
- name: Download Helm chart
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: helm-chart
path: ./artifacts/helm-chart
@@ -469,9 +667,17 @@ 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-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
+39 -163
View File
@@ -3,7 +3,6 @@ name: CI
on:
pull_request:
branches: [ main ]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
@@ -25,7 +24,7 @@ jobs:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -98,30 +97,6 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
test-ai-sdk-integration-deno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Run tests (Deno)
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
build-chat-integration:
runs-on: ubuntu-latest
@@ -224,6 +199,7 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -268,7 +244,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -445,6 +421,7 @@ jobs:
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -462,7 +439,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -507,6 +484,7 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -524,7 +502,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -609,6 +587,7 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -626,7 +605,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -707,119 +686,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-typescript-client-deno:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Build API
working-directory: ./hindsight-api-slim
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Install TypeScript client dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json
HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..120}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 120 ]; then
echo "API server failed to start after 120s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run TypeScript client tests (Deno)
working-directory: ./hindsight-clients/typescript
run: npm run test:deno
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
build-rust-cli-arm64:
runs-on: ubuntu-24.04-arm
@@ -853,6 +719,7 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -870,7 +737,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -959,6 +826,7 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -976,7 +844,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1063,6 +931,7 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1080,7 +949,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1169,6 +1038,7 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1186,7 +1056,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1271,7 +1141,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1300,7 +1170,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1329,7 +1199,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1356,7 +1226,7 @@ jobs:
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup GCP credentials
run: |
@@ -1365,13 +1235,13 @@ jobs:
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1412,6 +1282,7 @@ jobs:
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1429,7 +1300,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1469,6 +1340,7 @@ jobs:
HINDSIGHT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_LLM_MODEL: google/gemini-2.5-flash-lite
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1486,7 +1358,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1524,6 +1396,7 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1562,7 +1435,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1657,6 +1530,7 @@ jobs:
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1679,7 +1553,7 @@ jobs:
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1729,6 +1603,9 @@ jobs:
verify-generated-files:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1738,7 +1615,7 @@ jobs:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
@@ -1775,9 +1652,6 @@ 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
@@ -1792,7 +1666,6 @@ 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
@@ -1802,6 +1675,9 @@ jobs:
check-openapi-compatibility:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
with:
@@ -1813,7 +1689,7 @@ jobs:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
Generated
-139
View File
@@ -1,139 +0,0 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.17": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/expect@*": "1.0.18",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/testing@*": "1.0.17"
},
"jsr": {
"@std/[email protected]": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a",
"dependencies": [
"jsr:@std/assert@^1.0.19",
"jsr:@std/internal",
"jsr:@std/path"
]
},
"@std/[email protected]": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/[email protected]": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
"dependencies": [
"jsr:@std/assert@^1.0.17",
"jsr:@std/internal"
]
}
},
"workspace": {
"members": {
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
"npm:@hey-api/[email protected]",
"npm:@types/jest@29",
"npm:@types/node@20",
"npm:jest@29",
"npm:ts-jest@29",
"npm:tsup@^8.5.1",
"npm:typescript@5"
]
}
},
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
"npm:@radix-ui/react-checkbox@^1.3.3",
"npm:@radix-ui/react-dialog@^1.1.15",
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
"npm:@radix-ui/react-switch@^1.2.6",
"npm:@radix-ui/react-tabs@^1.1.13",
"npm:@radix-ui/react-tooltip@^1.2.8",
"npm:@tailwindcss/postcss@^4.1.17",
"npm:@tailwindcss/typography@~0.5.19",
"npm:@types/cytoscape@^3.21.9",
"npm:@types/node@^24.10.0",
"npm:@types/react-dom@^19.2.2",
"npm:@types/react@^19.2.2",
"npm:autoprefixer@^10.4.21",
"npm:class-variance-authority@~0.7.1",
"npm:clsx@^2.1.1",
"npm:cmdk@^1.1.1",
"npm:cytoscape-fcose@^2.2.0",
"npm:cytoscape@^3.33.1",
"npm:eslint-config-next@^16.0.1",
"npm:eslint-plugin-react-hooks@^7.0.1",
"npm:eslint-plugin-react@^7.37.5",
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
"npm:recharts@^3.5.1",
"npm:remark-gfm@^4.0.1",
"npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.4.0",
"npm:tailwindcss-animate@^1.0.7",
"npm:tailwindcss@^4.1.17",
"npm:[email protected]",
"npm:typescript-eslint@^8.50.0",
"npm:typescript@^5.9.3"
]
}
},
"hindsight-docs": {
"packageJson": {
"dependencies": [
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/theme-common@^3.9.2",
"npm:@docusaurus/theme-mermaid@^3.9.2",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@easyops-cn/docusaurus-search-local@~0.52.2",
"npm:@mdx-js/react@3",
"npm:clsx@2",
"npm:prism-react-renderer@^2.3.0",
"npm:raw-loader@^4.0.2",
"npm:react-dom@19",
"npm:react-icons@^5.6.0",
"npm:react@19",
"npm:redocusaurus@^2.5.0",
"npm:typescript@~5.6.2"
]
}
}
}
}
}
-1
View File
@@ -111,7 +111,6 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
CP_PID=$!
PIDS+=($CP_PID)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.19
appVersion: "0.4.19"
version: 0.4.17
appVersion: "0.4.17"
keywords:
- ai
- memory
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.19"
version = "0.4.17"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+43 -223
View File
@@ -13,10 +13,7 @@ from hindsight_client import Hindsight
class BanksAPI:
"""Namespace for bank-related operations.
Provides methods to create, delete, and manage memory banks.
"""
"""Namespace for bank-related operations."""
def __init__(self, client: Hindsight):
self._client = client
@@ -27,18 +24,8 @@ class BanksAPI:
name: str | None = None,
mission: str | None = None,
disposition: dict[str, Any] | None = None,
) -> Any:
"""Create a new bank.
Args:
bank_id: Unique identifier for the bank.
name: Optional display name for the bank.
mission: Optional mission statement for the bank.
disposition: Optional disposition configuration dict.
Returns:
Bank creation response from the API.
"""
):
"""Create a new bank."""
return self._client.create_bank(
bank_id=bank_id,
name=name,
@@ -46,57 +33,27 @@ class BanksAPI:
disposition=disposition,
)
def delete(self, bank_id: str) -> Any:
"""Delete a bank.
Args:
bank_id: The ID of the bank to delete.
Returns:
Deletion response from the API.
"""
def delete(self, bank_id: str):
"""Delete a bank."""
return self._client.delete_bank(bank_id=bank_id)
def set_mission(self, bank_id: str, mission: str) -> Any:
"""Set or update the mission for a bank.
Args:
bank_id: The ID of the bank.
mission: The mission statement to set.
Returns:
API response confirming the update.
"""
def set_mission(self, bank_id: str, mission: str):
"""Set or update the mission for a bank."""
return self._client.set_mission(bank_id=bank_id, mission=mission)
def set_disposition(self, bank_id: str, disposition: dict[str, Any]) -> Any:
"""Set or update the disposition for a bank.
Args:
bank_id: The ID of the bank.
disposition: The disposition configuration dict.
Returns:
API response confirming the update.
"""
def set_disposition(self, bank_id: str, disposition: dict[str, Any]):
"""Set or update the disposition for a bank."""
return self._client.set_disposition(bank_id=bank_id, disposition=disposition)
def list(self) -> Any:
"""List all banks.
Returns:
List of banks from the API.
"""
def list(self):
"""List all banks."""
from hindsight_client.hindsight_client import _run_async
return _run_async(self._client._banks_api.list_banks())
class MentalModelsAPI:
"""Namespace for mental model operations.
Mental models are reusable knowledge structures that guide agent behavior.
"""
"""Namespace for mental model operations."""
def __init__(self, client: Hindsight):
self._client = client
@@ -107,18 +64,8 @@ class MentalModelsAPI:
name: str,
content: str,
tags: list[str] | None = None,
) -> Any:
"""Create a new mental model.
Args:
bank_id: The ID of the bank to add the model to.
name: Name for the mental model.
content: The content/instructions for the mental model.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
):
"""Create a new mental model."""
return self._client.create_mental_model(
bank_id=bank_id,
name=name,
@@ -126,40 +73,16 @@ class MentalModelsAPI:
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all mental models for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of mental models.
"""
def list(self, bank_id: str, tags: list[str] | None = None):
"""List all mental models for a bank."""
return self._client.list_mental_models(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, mental_model_id: str) -> Any:
"""Get a specific mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model.
Returns:
The mental model details.
"""
def get(self, bank_id: str, mental_model_id: str):
"""Get a specific mental model."""
return self._client.get_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def refresh(self, bank_id: str, mental_model_id: str) -> Any:
"""Refresh a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to refresh.
Returns:
Refresh response from the API.
"""
def refresh(self, bank_id: str, mental_model_id: str):
"""Refresh a mental model."""
return self._client.refresh_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def update(
@@ -169,19 +92,8 @@ class MentalModelsAPI:
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> Any:
"""Update a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
):
"""Update a mental model."""
return self._client.update_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
@@ -190,24 +102,13 @@ class MentalModelsAPI:
tags=tags,
)
def delete(self, bank_id: str, mental_model_id: str) -> Any:
"""Delete a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to delete.
Returns:
Deletion response from the API.
"""
def delete(self, bank_id: str, mental_model_id: str):
"""Delete a mental model."""
return self._client.delete_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
class DirectivesAPI:
"""Namespace for directive operations.
Directives are explicit instructions that guide agent behavior.
"""
"""Namespace for directive operations."""
def __init__(self, client: Hindsight):
self._client = client
@@ -218,18 +119,8 @@ class DirectivesAPI:
name: str,
content: str,
tags: list[str] | None = None,
) -> Any:
"""Create a new directive.
Args:
bank_id: The ID of the bank to add the directive to.
name: Name for the directive.
content: The directive content/instructions.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
):
"""Create a new directive."""
return self._client.create_directive(
bank_id=bank_id,
name=name,
@@ -237,28 +128,12 @@ class DirectivesAPI:
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all directives for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of directives.
"""
def list(self, bank_id: str, tags: list[str] | None = None):
"""List all directives for a bank."""
return self._client.list_directives(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, directive_id: str) -> Any:
"""Get a specific directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive.
Returns:
The directive details.
"""
def get(self, bank_id: str, directive_id: str):
"""Get a specific directive."""
return self._client.get_directive(bank_id=bank_id, directive_id=directive_id)
def update(
@@ -268,19 +143,8 @@ class DirectivesAPI:
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> Any:
"""Update a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
):
"""Update a directive."""
return self._client.update_directive(
bank_id=bank_id,
directive_id=directive_id,
@@ -289,24 +153,13 @@ class DirectivesAPI:
tags=tags,
)
def delete(self, bank_id: str, directive_id: str) -> Any:
"""Delete a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to delete.
Returns:
Deletion response from the API.
"""
def delete(self, bank_id: str, directive_id: str):
"""Delete a directive."""
return self._client.delete_directive(bank_id=bank_id, directive_id=directive_id)
class MemoriesAPI:
"""Namespace for memory operations.
Provides methods to query and retrieve stored memories.
"""
"""Namespace for memory operations."""
def __init__(self, client: Hindsight):
self._client = client
@@ -318,19 +171,8 @@ class MemoriesAPI:
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
) -> Any:
"""List memories in a bank.
Args:
bank_id: The ID of the bank to query.
type: Optional filter by memory type.
search_query: Optional search query for filtering.
limit: Maximum number of results to return (default: 100).
offset: Number of results to skip for pagination (default: 0).
Returns:
List of memories matching the criteria.
"""
):
"""List memories in a bank."""
return self._client.list_memories(
bank_id=bank_id,
type=type,
@@ -363,15 +205,9 @@ class HindsightClient(Hindsight):
directives = client.directives.list(bank_id="test")
memories = client.memories.list(bank_id="test")
```
Attributes:
banks: Namespace for bank management operations.
mental_models: Namespace for mental model operations.
directives: Namespace for directive operations.
memories: Namespace for memory listing operations.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._banks_namespace: BanksAPI | None = None
self._mental_models_namespace: MentalModelsAPI | None = None
@@ -380,44 +216,28 @@ class HindsightClient(Hindsight):
@property
def banks(self) -> BanksAPI:
"""Access bank management operations.
Returns:
BanksAPI instance for bank operations.
"""
"""Access bank management operations."""
if self._banks_namespace is None:
self._banks_namespace = BanksAPI(self)
return self._banks_namespace
@property
def mental_models(self) -> MentalModelsAPI:
"""Access mental model operations.
Returns:
MentalModelsAPI instance for mental model operations.
"""
"""Access mental model operations."""
if self._mental_models_namespace is None:
self._mental_models_namespace = MentalModelsAPI(self)
return self._mental_models_namespace
@property
def directives(self) -> DirectivesAPI:
"""Access directive operations.
Returns:
DirectivesAPI instance for directive operations.
"""
"""Access directive operations."""
if self._directives_namespace is None:
self._directives_namespace = DirectivesAPI(self)
return self._directives_namespace
@property
def memories(self) -> MemoriesAPI:
"""Access memory listing operations.
Returns:
MemoriesAPI instance for memory operations.
"""
"""Access memory listing operations."""
if self._memories_namespace is None:
self._memories_namespace = MemoriesAPI(self)
return self._memories_namespace
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.19"
version = "0.4.17"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.19"
__version__ = "0.4.17"
@@ -1,52 +0,0 @@
"""Add consolidation_failed_at column to memory_units for tracking persistent LLM failures.
When all LLM retries are exhausted on a single-memory batch, the memory is marked
with consolidation_failed_at instead of consolidated_at, so it is not silently lost
and can be retried later via the API.
Revision ID: a3b4c5d6e7f8
Revises: g7h8i9j0k1l2
Create Date: 2026-03-17
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a3b4c5d6e7f8"
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidation_failed_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Index to efficiently query memories that failed consolidation for a given bank
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_consolidation_failed
ON {schema}memory_units (bank_id, consolidation_failed_at)
WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
@@ -1,38 +0,0 @@
"""chunk_fk_cascade_delete
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2026-03-16 00:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f6g7h8i9j0k1"
down_revision: str | Sequence[str] | None = "e5f6g7h8i9j0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Change memory_units.chunk_id FK from SET NULL to CASCADE.
When a document is deleted the CASCADE reaches chunks first; with SET NULL
the memory_units rows survived with chunk_id = NULL, leaving ghost records.
Switching to CASCADE ensures they are removed together with their chunk.
"""
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="CASCADE"
)
def downgrade() -> None:
"""Revert to SET NULL behaviour."""
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
)
@@ -1,71 +0,0 @@
"""backsweep_orphan_memory_units
Two-pass cleanup of memory_units rows that were never removed by earlier bugs:
Pass 1 — any fact_type, bank gone:
memory_units whose bank_id no longer exists in banks. These accumulate when
a bank is deleted without a proper cascade (no FK from memory_units to banks
exists in the schema).
Pass 2 — observations only, all sources gone:
observation rows whose bank still exists but every source_memory_id points
to a deleted memory unit. These were left behind before PR #580 fixed the
chunk FK cascade and before delete_document() called
_delete_stale_observations_for_memories.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2026-03-16
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "g7h8i9j0k1l2"
down_revision: str | Sequence[str] | None = "f6g7h8i9j0k1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
mu = f"{schema}memory_units"
banks = f"{schema}banks"
# Pass 1: delete all memory_units (any fact_type) whose bank no longer exists.
# There is no FK from memory_units to banks, so these never cascade away.
op.execute(
f"""
DELETE FROM {mu}
WHERE NOT EXISTS (
SELECT 1 FROM {banks} b WHERE b.bank_id = {mu}.bank_id
)
"""
)
# Pass 2: delete orphaned observations whose bank still exists but every
# source_memory_id refers to a now-deleted memory unit (or the array is
# empty). Observations with at least one surviving source are left alone.
op.execute(
f"""
DELETE FROM {mu} orphan
WHERE orphan.fact_type = 'observation'
AND NOT EXISTS (
SELECT 1
FROM {mu} src
WHERE src.id = ANY(orphan.source_memory_ids)
AND src.bank_id = orphan.bank_id
)
"""
)
def downgrade() -> None:
# Deleted rows cannot be restored.
pass
+26 -148
View File
@@ -10,7 +10,7 @@ import json
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from datetime import datetime
from typing import Any, Literal
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
@@ -425,11 +425,6 @@ class MemoryItem(BaseModel):
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
),
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
@field_validator("timestamp", mode="before")
@classmethod
@@ -496,11 +491,6 @@ class FileRetainMetadata(BaseModel):
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this file. Overrides the bank's default strategy. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
class FileRetainRequest(BaseModel):
@@ -554,11 +544,7 @@ class RetainResponse(BaseModel):
)
operation_id: str | None = Field(
default=None,
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.",
)
operation_ids: list[str] | None = Field(
default=None,
description="Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.",
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.",
)
usage: TokenUsage | None = Field(
default=None,
@@ -669,25 +655,6 @@ 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":
@@ -1347,14 +1314,6 @@ class ClearMemoryObservationsResponse(BaseModel):
deleted_count: int
class RecoverConsolidationResponse(BaseModel):
"""Response model for recovering failed consolidation."""
model_config = ConfigDict(json_schema_extra={"example": {"retried_count": 42}})
retried_count: int
class BankStatsResponse(BaseModel):
"""Response model for bank statistics endpoint."""
@@ -1454,25 +1413,6 @@ 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):
@@ -2543,9 +2483,6 @@ 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
@@ -3951,34 +3888,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidation/recover",
response_model=RecoverConsolidationResponse,
summary="Recover failed consolidation",
description=(
"Reset all memories that were permanently marked as failed during consolidation "
"(after exhausting all LLM retries and adaptive batch splitting) so they are "
"picked up again on the next consolidation run. Does not delete any observations."
),
operation_id="recover_consolidation",
tags=["Banks"],
)
async def api_recover_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Reset consolidation-failed memories for recovery."""
try:
result = await app.state.memory.retry_failed_consolidation(bank_id, request_context=request_context)
return RecoverConsolidationResponse(retried_count=result["retried_count"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidation/recover: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/memories/{memory_id}/observations",
response_model=ClearMemoryObservationsResponse,
@@ -4212,7 +4121,7 @@ def _register_routes(app: FastAPI):
await bank_utils.get_bank_profile(pool, bank_id)
webhook_id = uuid.uuid4()
now = datetime.now(timezone.utc).isoformat()
now = datetime.utcnow().isoformat() + "Z"
row = await pool.fetchrow(
f"""
INSERT INTO {fq_table("webhooks")}
@@ -4532,13 +4441,10 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
try:
# Group items by strategy
strategy_groups: dict[str | None, list[dict]] = {}
# Prepare contents for processing
contents = []
for item in request.items:
effective = item.strategy
if effective not in strategy_groups:
strategy_groups[effective] = []
content_dict: dict = {"content": item.content}
content_dict = {"content": item.content}
if item.timestamp == "unset":
content_dict["event_date"] = None
elif item.timestamp:
@@ -4555,30 +4461,20 @@ def _register_routes(app: FastAPI):
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
strategy_groups[effective].append(content_dict)
contents.append(content_dict)
if request.async_:
# Async processing: one submit per strategy group
all_operation_ids = []
total_items_count = 0
for group_strategy, contents in strategy_groups.items():
result = await app.state.memory.submit_async_retain(
bank_id,
contents,
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
)
all_operation_ids.append(result["operation_id"])
total_items_count += result["items_count"]
# Async processing: queue task and return immediately
result = await app.state.memory.submit_async_retain(
bank_id, contents, document_tags=request.document_tags, request_context=request_context
)
return RetainResponse.model_validate(
{
"success": True,
"bank_id": bank_id,
"items_count": total_items_count,
"items_count": result["items_count"],
"async": True,
"operation_id": all_operation_ids[0] if all_operation_ids else None,
"operation_ids": all_operation_ids if len(all_operation_ids) > 1 else None,
"operation_id": result["operation_id"],
}
)
else:
@@ -4597,41 +4493,24 @@ def _register_routes(app: FastAPI):
),
)
# Synchronous processing: one batch per strategy group, aggregate results
total_items_count = 0
total_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
# Synchronous processing: wait for completion (record metrics)
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
for group_strategy, contents in strategy_groups.items():
result, usage = await app.state.memory.retain_batch_async(
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_tags=request.document_tags,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
total_items_count += len(contents)
if usage:
total_usage = TokenUsage(
input_tokens=total_usage.input_tokens + usage.input_tokens,
output_tokens=total_usage.output_tokens + usage.output_tokens,
total_tokens=total_usage.total_tokens + usage.total_tokens,
)
operation_id=None,
schema=_current_schema.get(),
),
)
return RetainResponse.model_validate(
{
"success": True,
"bank_id": bank_id,
"items_count": total_items_count,
"async": False,
"usage": total_usage,
}
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -4794,7 +4673,6 @@ def _register_routes(app: FastAPI):
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
"strategy": file_meta.strategy,
}
file_items.append(item)
@@ -381,15 +381,6 @@ class MCPMiddleware:
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Ensure Accept header includes required MIME types for MCP SDK.
# Some clients (e.g., Claude Code) don't send Accept, causing
# the SDK to reject with 406 Not Acceptable.
accept_header = self._get_header(new_scope, "accept")
if not accept_header or "text/event-stream" not in accept_header:
headers = [(k, v) for k, v in new_scope.get("headers", []) if k.lower() != b"accept"]
headers.append((b"accept", b"application/json, text/event-stream"))
new_scope["headers"] = headers
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
# that might contain the literal string "data: /messages".
+2 -29
View File
@@ -212,9 +212,6 @@ ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
ENV_RERANKER_LOCAL_FP16 = "HINDSIGHT_API_RERANKER_LOCAL_FP16"
ENV_RERANKER_LOCAL_BUCKET_BATCHING = "HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING"
ENV_RERANKER_LOCAL_BATCH_SIZE = "HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
@@ -267,7 +264,6 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
@@ -356,7 +352,7 @@ PROVIDER_DEFAULT_MODELS = {
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"minimax": "MiniMax-M2.5",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
@@ -393,9 +389,6 @@ DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound rerankin
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
)
DEFAULT_RERANKER_LOCAL_FP16 = False # FP16 inference: opt-in, faster on MPS/CUDA (not CPU)
DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING = False # Length-sorted bucket batching: opt-in, 36-54% speedup
DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 32 # Batch size for local reranker predict() calls
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
DEFAULT_RERANKER_MAX_CANDIDATES = 300
@@ -444,11 +437,9 @@ DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction L
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
@@ -677,9 +668,6 @@ class HindsightConfig:
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_local_fp16: bool
reranker_local_bucket_batching: bool
reranker_local_batch_size: int
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
@@ -722,8 +710,6 @@ class HindsightConfig:
retain_extraction_mode: str
retain_mission: str | None
retain_custom_instructions: str | None
retain_default_strategy: str | None
retain_strategies: dict | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
@@ -854,8 +840,6 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
"retain_default_strategy",
"retain_strategies",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
@@ -1108,15 +1092,6 @@ class HindsightConfig:
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
in ("true", "1"),
reranker_local_bucket_batching=os.getenv(
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
).lower()
in ("true", "1"),
reranker_local_batch_size=int(
os.getenv(ENV_RERANKER_LOCAL_BATCH_SIZE, str(DEFAULT_RERANKER_LOCAL_BATCH_SIZE))
),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
reranker_tei_max_concurrent=int(
@@ -1182,8 +1157,6 @@ class HindsightConfig:
),
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
@@ -10,7 +10,7 @@ multiple API servers.
import json
import logging
from dataclasses import asdict, replace
from dataclasses import asdict
from typing import Any
import asyncpg
@@ -239,14 +239,6 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
if empty_keys:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
@@ -281,35 +273,3 @@ class ConfigResolver:
)
logger.info(f"Reset bank config for {bank_id} to defaults")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
"""
strategies = config.retain_strategies or {}
if strategy_name not in strategies:
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
return config
overrides = strategies[strategy_name]
if not isinstance(overrides, dict):
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
return config
configurable = HindsightConfig.get_configurable_fields()
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
@@ -80,7 +80,6 @@ class _BatchLLMResult:
deletes: list[_DeleteAction] = field(default_factory=list)
obs_count: int = 0
prompt_chars: int = 0
failed: bool = False
@dataclass
@@ -220,7 +219,6 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
@@ -242,7 +240,6 @@ async def run_consolidation_job(
"observations_deleted": 0,
"actions_executed": 0,
"skipped": 0,
"memories_failed": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
@@ -260,7 +257,6 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
@@ -302,141 +298,94 @@ async def run_consolidation_job(
if memory_tags:
consolidated_tags.update(memory_tags)
# Process llm_batch with adaptive splitting: on LLM failure, halve the sub-batch
# and retry, down to batch_size=1. Only if a single-memory batch still fails is
# the memory marked with consolidation_failed_at and excluded from future runs
# until explicitly retried via the API.
all_results: list[dict[str, Any]] = []
all_deleted = 0
succeeded_ids: list[Any] = []
failed_ids: list[Any] = []
async with pool.acquire() as conn:
# Determine observation_scopes for this batch. All memories in a batch share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
pending: list[list[dict[str, Any]]] = [llm_batch]
while pending:
sub_batch = pending.pop(0)
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
async with pool.acquire() as conn:
# Determine observation_scopes for this sub-batch. All memories share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = sub_batch[0].get("observation_scopes") if sub_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
sub_deleted: int = 0
sub_llm_failed = False
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
sub_results: list[dict[str, Any]] = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted, pass_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
sub_deleted += pass_deleted
sub_llm_failed = sub_llm_failed or pass_failed
# Merge results: prefer non-skipped actions
if not sub_results:
sub_results = pass_results
else:
for i, (existing, new) in enumerate(zip(sub_results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
sub_results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
sub_results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
sub_results, sub_deleted, sub_llm_failed = await _process_memory_batch(
batch_deleted: int = 0
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
results = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=sub_batch,
memories=llm_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
all_deleted += sub_deleted
if sub_llm_failed and len(sub_batch) > 1:
# Split and retry with smaller batches
mid = len(sub_batch) // 2
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for sub-batch of {len(sub_batch)},"
f" splitting into {mid}/{len(sub_batch) - mid}"
)
pending[0:0] = [sub_batch[:mid], sub_batch[mid:]]
elif sub_llm_failed:
# batch_size=1 and still failing — mark as permanently failed for now
failed_ids.append(sub_batch[0]["id"])
all_results.append({"action": "failed"})
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for single memory"
f" {sub_batch[0]['id']}, marking consolidation_failed_at"
)
batch_deleted += pass_deleted
# Merge results: prefer non-skipped actions
if not results:
results = pass_results
else:
for i, (existing, new) in enumerate(zip(results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
succeeded_ids.extend(m["id"] for m in sub_batch)
all_results.extend(sub_results)
# Commit consolidated_at / consolidation_failed_at in a single DB round-trip
async with pool.acquire() as conn:
if succeeded_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in succeeded_ids],
)
if failed_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidation_failed_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in failed_ids],
# Normal single pass using the memory's own tags
results, batch_deleted = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
perf=perf,
config=config,
)
stats["observations_deleted"] += batch_deleted
stats["observations_deleted"] += all_deleted
results = all_results
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(m["id"],) for m in llm_batch],
)
# Checkpoint: abort if the operation (and thus the bank) was deleted mid-run.
if operation_id and not await memory_engine._check_op_alive(operation_id):
@@ -464,8 +413,6 @@ async def run_consolidation_job(
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
elif action == "failed":
stats["memories_failed"] += 1
# Per-LLM-batch log
llm_batch_time = time.time() - llm_batch_start
@@ -478,7 +425,6 @@ async def run_consolidation_job(
batch_created = stats["observations_created"] - snap_stats["observations_created"]
batch_updated = stats["observations_updated"] - snap_stats["observations_updated"]
batch_skipped = stats["skipped"] - snap_stats["skipped"]
batch_failed = stats["memories_failed"] - snap_stats["memories_failed"]
llm_calls_made = perf.llm_calls - snap_llm_calls
logger.info(
f"[CONSOLIDATION] bank={bank_id} llm_batch #{llm_batch_num}"
@@ -486,8 +432,7 @@ async def run_consolidation_job(
f" | {stats['memories_processed']}/{total_count} processed"
f" | {', '.join(timing_parts)}"
f" | created={batch_created} updated={batch_updated} skipped={batch_skipped}"
+ (f" failed={batch_failed}" if batch_failed else "")
+ f" | input_tokens=~{input_tokens}"
f" | input_tokens=~{input_tokens}"
f" | avg={llm_batch_time / len(llm_batch):.3f}s/memory"
)
@@ -639,7 +584,7 @@ async def _process_memory_batch(
perf: ConsolidationPerfLog | None = None,
config: Any = None,
obs_tags_override: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int, bool]:
) -> tuple[list[dict[str, Any]], int]:
"""
Process a batch of memories in a single LLM call.
@@ -802,7 +747,7 @@ async def _process_memory_batch(
else:
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
return results, deleted_count, llm_result.failed
return results, deleted_count
def _min_date(dates: "Any") -> "datetime | None":
@@ -1136,7 +1081,7 @@ async def _consolidate_batch_with_llm(
logger.error(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts, skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt))
async def _create_observation_directly(
@@ -23,7 +23,6 @@ from ..config import (
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
@@ -112,9 +111,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
fp16: bool = False,
bucket_batching: bool = False,
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -129,20 +125,10 @@ class LocalSTCrossEncoder(CrossEncoderModel):
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
Default: False (disabled for security)
fp16: Use FP16 (half precision) inference. Faster on MPS and CUDA,
may be slower on CPU. Default: False (opt-in via env var).
bucket_batching: Sort pairs by token length before batching to reduce
padding waste. 36-54% speedup, quality-identical.
Default: False (opt-in via env var).
batch_size: Batch size for predict() calls. Optimal values vary by
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self.fp16 = fp16
self.bucket_batching = bucket_batching
self.batch_size = batch_size
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -190,24 +176,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
# create_position_ids_from_input_ids as a module-level function; the custom
# code in these models still references it. This monkey-patch restores it.
try:
import transformers.models.xlm_roberta.modeling_xlm_roberta as xlm_module
from transformers.models.xlm_roberta.modeling_xlm_roberta import XLMRobertaEmbeddings
if not hasattr(xlm_module, "create_position_ids_from_input_ids"):
setattr(
xlm_module,
"create_position_ids_from_input_ids",
XLMRobertaEmbeddings.create_position_ids_from_input_ids,
)
logger.info("Reranker: applied transformers 5.x compatibility patch for XLM-RoBERTa")
except Exception:
pass
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
@@ -232,12 +200,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Restore original logging level
transformers_logger.setLevel(original_level)
# FP16 inference: convert model weights to half precision.
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
if self.fp16 and device != "cpu":
self._model.model.half()
logger.info("Reranker: FP16 inference enabled")
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
LocalSTCrossEncoder._executor = ThreadPoolExecutor(
@@ -249,32 +211,8 @@ class LocalSTCrossEncoder(CrossEncoderModel):
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution.
Supports two optimizations (controlled via .env):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
if self.bucket_batching and len(pairs) > 1:
# Sort pairs by approximate token length to create homogeneous batches.
# This eliminates padding waste — short pairs aren't padded to the length
# of the longest pair in the batch. Quality-identical by construction.
lengths = [len(pairs[i][0]) + len(pairs[i][1]) for i in range(len(pairs))]
sorted_indices = sorted(range(len(pairs)), key=lambda i: lengths[i])
sorted_pairs = [pairs[i] for i in sorted_indices]
sorted_scores = self._model.predict(sorted_pairs, batch_size=self.batch_size, show_progress_bar=False)
sorted_scores = sorted_scores.tolist() if hasattr(sorted_scores, "tolist") else list(sorted_scores)
# Restore original order
scores = [0.0] * len(pairs)
for new_pos, orig_idx in enumerate(sorted_indices):
scores[orig_idx] = sorted_scores[new_pos]
return scores
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
@@ -1258,9 +1196,6 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
@@ -477,42 +477,19 @@ 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).
#
# 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:
missing = [n for n, _ in sorted_groups if n not in id_by_name]
if missing:
existing_rows = await conn.fetch(
f"""
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
SELECT id, LOWER(canonical_name) AS name_lower
FROM {fq_table("entities")}
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
""",
bank_id,
missing_original,
missing,
)
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,
@@ -633,7 +633,7 @@ class LLMProvider:
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
from claude_agent_sdk import query # noqa: F401
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -561,7 +561,6 @@ class MemoryEngine(MemoryEngineInterface):
contents = task_dict.get("contents", [])
document_tags = task_dict.get("document_tags")
operation_id = task_dict.get("operation_id") # For batch API crash recovery
strategy = task_dict.get("strategy")
logger.info(
f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}"
@@ -585,7 +584,6 @@ class MemoryEngine(MemoryEngineInterface):
document_tags=document_tags,
request_context=context,
operation_id=operation_id,
strategy=strategy,
outbox_callback=self._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
@@ -714,8 +712,6 @@ class MemoryEngine(MemoryEngineInterface):
retain_task_payload: dict[str, Any] = {"contents": retain_contents}
if document_tags:
retain_task_payload["document_tags"] = document_tags
if task_dict.get("strategy"):
retain_task_payload["strategy"] = task_dict["strategy"]
# Pass tenant/api_key context through to retain task
if task_dict.get("_tenant_id"):
@@ -868,23 +864,14 @@ 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,
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
exclude_mental_model_ids=[mental_model_id],
)
generated_content = reflect_result.text or "No content generated"
@@ -1966,7 +1953,6 @@ class MemoryEngine(MemoryEngineInterface):
return_usage: bool = False,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
):
"""
Store multiple content items as memory units in ONE batch operation.
@@ -2124,7 +2110,6 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
# Outbox callback runs inside the last sub-batch's transaction so the
# webhook delivery row is committed atomically with the final retain data.
outbox_callback=outbox_callback if i == len(sub_batches) else None,
@@ -2149,7 +2134,6 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
outbox_callback=outbox_callback,
)
@@ -2202,7 +2186,6 @@ class MemoryEngine(MemoryEngineInterface):
document_tags: list[str] | None = None,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
) -> tuple[list[list[str]], "TokenUsage"]:
"""
Internal method for batch processing without chunking logic.
@@ -2235,13 +2218,6 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Apply strategy overrides: explicit strategy > bank default strategy
from hindsight_api.config_resolver import apply_strategy
effective_strategy = strategy or resolved_config.retain_default_strategy
if effective_strategy:
resolved_config = apply_strategy(resolved_config, effective_strategy)
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@@ -3887,58 +3863,6 @@ class MemoryEngine(MemoryEngineInterface):
return {"deleted_count": count or 0}
async def retry_failed_consolidation(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Reset memories that previously failed consolidation so they are retried on the next
consolidation run.
Clears consolidation_failed_at (and consolidated_at) for all memories in the bank
that were marked as permanently failed after exhausting all LLM retries and adaptive
batch splitting. Does not delete any observations.
Args:
bank_id: Bank ID
request_context: Request context for authentication.
Returns:
Dictionary with count of memories queued for retry.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankWriteContext
ctx = BankWriteContext(
bank_id=bank_id, operation="retry_failed_consolidation", request_context=request_context
)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
count = await conn.fetchval(
f"""
SELECT COUNT(*) FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidation_failed_at IS NOT NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidation_failed_at = NULL, consolidated_at = NULL
WHERE bank_id = $1
AND consolidation_failed_at IS NOT NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
return {"retried_count": count or 0}
async def clear_observations_for_memory(
self,
bank_id: str,
@@ -5122,8 +5046,6 @@ 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:
"""
@@ -5244,11 +5166,6 @@ 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,
@@ -5260,7 +5177,6 @@ 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]:
@@ -5283,17 +5199,15 @@ class MemoryEngine(MemoryEngineInterface):
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
# 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")
# 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")
# Run the agent with parent span for reflect operation (skip if called from another operation)
if not _skip_span:
@@ -5318,8 +5232,6 @@ class MemoryEngine(MemoryEngineInterface):
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,
)
@@ -6451,12 +6363,6 @@ 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(
@@ -6465,9 +6371,7 @@ class MemoryEngine(MemoryEngineInterface):
request_context=request_context,
tags=tags,
tags_match=tags_match,
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
exclude_mental_model_ids=[mental_model_id],
_skip_span=True,
)
@@ -7473,7 +7377,6 @@ class MemoryEngine(MemoryEngineInterface):
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
strategy: str | None = None,
) -> dict[str, Any]:
"""Submit a batch retain operation to run asynchronously.
@@ -7582,8 +7485,6 @@ class MemoryEngine(MemoryEngineInterface):
task_payload: dict[str, Any] = {"contents": sub_batch}
if document_tags:
task_payload["document_tags"] = document_tags
if strategy:
task_payload["strategy"] = strategy
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:
task_payload["_tenant_id"] = request_context.tenant_id
@@ -7698,8 +7599,6 @@ class MemoryEngine(MemoryEngineInterface):
"document_tags": document_tags or [],
"timestamp": item.get("timestamp"),
}
if item.get("strategy"):
task_payload["strategy"] = item["strategy"]
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:
@@ -68,7 +68,7 @@ class ClaudeCodeLLM(LLMInterface):
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
from claude_agent_sdk import query # noqa: F401
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -141,12 +141,7 @@ class ClaudeCodeLLM(LLMInterface):
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
Exception: Re-raises API errors after retries exhausted.
"""
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
TextBlock,
query,
)
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
start_time = time.time()
@@ -336,7 +331,7 @@ class ClaudeCodeLLM(LLMInterface):
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from claude_agent_sdk import ( # type: ignore[unresolved-import]
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
@@ -470,11 +470,9 @@ class GeminiLLM(LLMInterface):
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
thought_signature = tc.get("thought_signature")
fc_kwargs: dict[str, Any] = {"name": fn_name, "args": fn_args}
if thought_signature:
fc_kwargs["thought_signature"] = thought_signature
parts.append(genai_types.Part(function_call=genai_types.FunctionCall(**fc_kwargs)))
parts.append(
genai_types.Part(function_call=genai_types.FunctionCall(name=fn_name, args=fn_args))
)
gemini_contents.append(genai_types.Content(role="model", parts=parts))
else:
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
@@ -547,13 +545,11 @@ class GeminiLLM(LLMInterface):
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
thought_signature = getattr(fc, "thought_signature", None)
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
thought_signature=thought_signature,
)
)
@@ -6,7 +6,7 @@ This provider handles all OpenAI API-compatible models including:
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models with 1M context window
- MiniMax: MiniMax-M2.5 models with 204K context window
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -48,7 +48,7 @@ class OpenAICompatibleLLM(LLMInterface):
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- MiniMax: MiniMax-M2.5 models via OpenAI-compatible API (https://api.minimax.io/v1)
"""
def __init__(
@@ -316,8 +316,6 @@ 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:
@@ -357,14 +355,7 @@ 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,
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")
tools = get_reflect_tools(directive_rules=directive_rules)
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(
@@ -547,18 +538,19 @@ async def run_reflect_agent(
llm_start = time.time()
# Determine tool_choice for this iteration.
# 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]}}
# 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"}}
else:
iter_tool_choice = "auto"
@@ -777,17 +769,7 @@ 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:
# 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)
# Add assistant message with tool calls
messages.append(
{
"role": "assistant",
@@ -795,23 +777,6 @@ 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(
@@ -820,7 +785,6 @@ async def run_reflect_agent(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
for tc in other_tools
]
@@ -931,7 +895,7 @@ async def run_reflect_agent(
def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"""Convert LLMToolCall to OpenAI message format."""
d: dict[str, Any] = {
return {
"id": tc.id,
"type": "function",
"function": {
@@ -939,9 +903,6 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
"arguments": json.dumps(tc.arguments),
},
}
if tc.thought_signature is not None:
d["thought_signature"] = tc.thought_signature
return d
async def _process_done_tool(
@@ -1010,7 +971,6 @@ 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
@@ -1044,7 +1004,6 @@ async def _execute_tool_with_timing(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
# Set success attributes
@@ -1084,16 +1043,11 @@ 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,7 +200,6 @@ 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.
@@ -218,18 +217,15 @@ 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=recall_fact_type,
fact_type=["experience", "world"],
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
@@ -227,12 +227,7 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> 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]:
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -244,23 +239,16 @@ def get_reflect_tools(
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 = []
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)
tools = [
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS,
TOOL_RECALL,
TOOL_EXPAND,
]
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -20,10 +20,6 @@ class LLMToolCall(BaseModel):
id: str = Field(description="Unique identifier for this tool call")
name: str = Field(description="Name of the tool to call")
arguments: dict[str, Any] = Field(description="Arguments to pass to the tool")
thought_signature: str | None = Field(
default=None,
description="Opaque token required by Gemini 3.1+ thinking models to preserve thought context across turns",
)
class LLMToolCallResult(BaseModel):
@@ -332,43 +332,6 @@ class FactExtractionResponseNoCausal(BaseModel):
facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
class VerbatimExtractedFact(BaseModel):
"""
Schema for verbatim extraction mode.
Omits 'what' entirely — the original chunk text is used as fact_text in code.
The LLM only extracts metadata: entities, temporal info, location, people.
"""
model_config = ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": ["when", "where", "who", "fact_type"]},
)
when: str = Field(description="When it happened. 'N/A' if unknown.")
where: str = Field(description="Location if relevant. 'N/A' if none.")
who: str = Field(description="People involved with relationships. 'N/A' if general.")
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
class VerbatimFactExtractionResponse(BaseModel):
"""Response for verbatim extraction mode (one entry per chunk, no fact text)."""
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
@@ -589,27 +552,6 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
examples="", # No examples for custom mode
)
# Verbatim mode: preserve the original text exactly, but still extract metadata
_VERBATIM_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
VERBATIM MODE — Extract metadata only
══════════════════════════════════════════════════════════════════════════
The original text will be stored as-is in code. Your ONLY job is to extract metadata.
RULES:
- Produce EXACTLY ONE entry per input chunk.
- DO NOT include a "what" field — it is not part of the output schema.
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
extraction_guidelines=_VERBATIM_GUIDELINES,
examples="",
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -828,10 +770,6 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
elif extraction_mode == "verbose":
prompt = VERBOSE_FACT_EXTRACTION_PROMPT
elif extraction_mode == "verbatim":
prompt = VERBATIM_FACT_EXTRACTION_PROMPT.format(
retain_mission_section=retain_mission_section,
)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
@@ -839,11 +777,7 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
# Add causal relationships section if enabled
# Verbatim mode never uses causal relations (no fact text to relate causally)
if extraction_mode == "verbatim":
base_fact_class = VerbatimExtractedFact
base_response_class = VerbatimFactExtractionResponse
elif extract_causal_links:
if extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
@@ -1078,21 +1012,33 @@ async def _extract_facts_from_chunk(
if not what:
what = get_value("factual_core")
if not what:
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
if extraction_mode != "verbatim":
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# 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":
# 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":
fact_type = "experience"
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"
# 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})"
)
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -1100,23 +1046,19 @@ async def _extract_facts_from_chunk(
fact_kind = "conversation"
# Build combined fact text from the 4 dimensions: what | when | who | why
# In verbatim mode, leave combined_text empty — _collapse_to_verbatim backfills it
fact_data = {}
if extraction_mode == "verbatim":
combined_text = ""
else:
combined_parts = [what]
combined_parts = [what]
if when:
combined_parts.append(f"When: {when}")
if when:
combined_parts.append(f"When: {when}")
if who:
combined_parts.append(f"Involving: {who}")
if who:
combined_parts.append(f"Involving: {who}")
if why:
combined_parts.append(why)
if why:
combined_parts.append(why)
combined_text = " | ".join(combined_parts)
combined_text = " | ".join(combined_parts)
# Add temporal fields
# For events: occurred_start/occurred_end (when the event happened)
@@ -1740,17 +1682,23 @@ async def extract_facts_from_contents_batch_api(
who = get_value("who")
why = get_value("why")
# 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":
# Critical field: fact_type
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience"
if fact_type == "assistant":
fact_type = "experience"
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"
# 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"
# Build combined fact text
combined_parts = [what]
@@ -1913,7 +1861,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="experience" if fact_from_llm.fact_type == "assistant" else "world",
fact_type=fact_from_llm.fact_type,
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,
@@ -1941,52 +1889,6 @@ async def extract_facts_from_contents_batch_api(
return extracted_facts, chunks_metadata, total_usage
def _extract_facts_chunks(
contents: list[RetainContent],
config,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
chunks mode: no LLM call, no entity extraction.
Each chunk becomes one memory unit with the raw text as fact_text.
User-provided entities from RetainContent.entities are picked up downstream
by entity_processing.py — they are the sole source of entity data in this mode.
"""
extracted_facts: list[ExtractedFactType] = []
chunks_metadata: list[ChunkMetadata] = []
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk,
fact_count=1,
content_index=content_index,
chunk_index=global_chunk_idx,
)
)
extracted_facts.append(
ExtractedFactType(
fact_text=chunk,
fact_type="world",
entities=[],
content_index=content_index,
chunk_index=global_chunk_idx,
context=content.context,
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
)
global_chunk_idx += 1
_add_temporal_offsets(extracted_facts, contents)
return extracted_facts, chunks_metadata, TokenUsage()
async def extract_facts_from_contents(
contents: list[RetainContent],
llm_config,
@@ -2022,11 +1924,6 @@ async def extract_facts_from_contents(
if not contents:
return [], [], TokenUsage()
# chunks mode: skip LLM entirely, store each chunk as-is
# Must come before the batch-API check so no LLM queue/locks are acquired
if config.retain_extraction_mode == "chunks":
return _extract_facts_chunks(contents, config)
# Route to batch API if enabled
if config.retain_batch_enabled:
return await extract_facts_from_contents_batch_api(
@@ -2090,7 +1987,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="experience" if fact_from_llm.fact_type == "assistant" else "world",
fact_type=fact_from_llm.fact_type,
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)
@@ -2116,46 +2013,15 @@ async def extract_facts_from_contents(
global_fact_idx += 1
fact_idx_in_content += 1
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
if config.retain_extraction_mode == "verbatim":
extracted_facts = _collapse_to_verbatim(extracted_facts, chunks_metadata)
# Step 5: Add time offsets to preserve ordering within each content
# Step 4: Add time offsets to preserve ordering within each content
_add_temporal_offsets(extracted_facts, contents)
# Step 6: Auto-tag facts from label groups with tag=True
# Step 5: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
return extracted_facts, chunks_metadata, total_usage
def _collapse_to_verbatim(facts: list[ExtractedFactType], chunks: list[ChunkMetadata]) -> list[ExtractedFactType]:
"""
For verbatim mode: ensure one fact per chunk with the original chunk text preserved.
The LLM prompt asks for exactly one fact per chunk, but if it returns more,
this collapses them: keeps the first fact as representative, overrides its
fact_text with the raw chunk text, and merges entities from any extra facts.
"""
chunk_text_map = {c.chunk_index: c.chunk_text for c in chunks}
seen: dict[int, ExtractedFactType] = {}
result: list[ExtractedFactType] = []
for fact in facts:
if fact.chunk_index not in seen:
fact.fact_text = chunk_text_map.get(fact.chunk_index, fact.fact_text)
seen[fact.chunk_index] = fact
result.append(fact)
else:
# Merge entities from extra facts into the representative
representative = seen[fact.chunk_index]
for entity in fact.entities:
if entity not in representative.entities:
representative.entities.append(entity)
return result
def _parse_datetime(date_str: str):
"""Parse ISO datetime string."""
from dateutil import parser as date_parser
-5
View File
@@ -219,9 +219,6 @@ def main():
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,
@@ -257,8 +254,6 @@ def main():
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,
+5 -12
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.4.19"
version = "0.4.17"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -55,7 +55,7 @@ dependencies = [
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.6", # Account takeover vulnerability 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]
@@ -168,16 +168,9 @@ quote-style = "double"
indent-style = "space"
[tool.uv]
# Use explicit index for PyTorch to prevent the pytorch index from serving
# non-pytorch packages (e.g. markupsafe) with incompatible wheels
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Route torch to the CPU-only PyTorch index; everything else uses PyPI
torch = { index = "pytorch-cpu" }
# Allow uv to search all configured indexes for packages, not just the first one
# This prevents dependency resolution failures when using pytorch index + PyPI
index-strategy = "unsafe-best-match"
[tool.ty]
# Type checking configuration
+6 -20
View File
@@ -48,8 +48,7 @@ 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 a plain postgresql:// URL, uses it directly.
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
Otherwise, starts pg0 once for the entire test session.
Uses filelock to ensure only one pytest-xdist worker starts pg0.
@@ -59,22 +58,9 @@ 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.
"""
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
# Use provided database URL directly
return db_url
# Get shared temp dir for coordination between xdist workers
if worker_id == "master":
@@ -85,8 +71,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 / f"pg0_setup_{pg0_instance_name}.lock"
url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
lock_file = root_tmp_dir / "pg0_setup.lock"
url_file = root_tmp_dir / "pg0_url.txt"
with filelock.FileLock(str(lock_file)):
if url_file.exists():
@@ -94,7 +80,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=pg0_instance_name, port=pg0_instance_port)
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
# Run ensure_running in a new event loop
loop = asyncio.new_event_loop()
@@ -1,476 +0,0 @@
"""Tests for consolidation failure handling: adaptive batch splitting, consolidation_failed_at,
and the recovery API.
These tests use a mock LLM to simulate LLM failures deterministically, without making real
API calls. All tests insert memories directly into the database to bypass retain's LLM calls
and focus exclusively on the consolidation code paths.
"""
import uuid
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.providers.mock_llm import MockLLM
from hindsight_api.engine.task_backend import SyncTaskBackend
@pytest_asyncio.fixture(scope="function")
async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""MemoryEngine with mock LLM.
Migrations are already applied by the session-scoped pg0_db_url fixture, so
run_migrations=False avoids advisory-lock serialization overhead per test.
"""
mem = MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="mock",
memory_llm_api_key="",
memory_llm_model="mock",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False,
task_backend=SyncTaskBackend(),
skip_llm_verification=True,
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
from hindsight_api.config import _get_raw_config
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
def _make_failing_mock_llm(*, fail_first_n: int = 999) -> MockLLM:
"""Return a MockLLM that raises ValueError for the first `fail_first_n` consolidation calls."""
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
call_count = 0
def callback(messages, scope):
nonlocal call_count
if scope == "consolidation":
call_count += 1
if call_count <= fail_first_n:
raise ValueError(f"Simulated LLM failure (call {call_count})")
# Return empty response — no creates/updates/deletes
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
return _ConsolidationBatchResponse()
mock_llm.set_response_callback(callback)
return mock_llm
def _make_always_success_mock_llm() -> MockLLM:
"""Return a MockLLM that always succeeds with an empty consolidation response."""
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
def callback(messages, scope):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
return _ConsolidationBatchResponse()
mock_llm.set_response_callback(callback)
return mock_llm
def _inject_mock_llm(memory: MemoryEngine, mock_llm: MockLLM) -> None:
"""Replace memory._consolidation_llm_config with a wrapper that returns mock_llm from with_config."""
wrapper = MagicMock()
wrapper.with_config.return_value = mock_llm
memory._consolidation_llm_config = wrapper
async def _insert_memories(conn, bank_id: str, texts: list[str]) -> list[uuid.UUID]:
"""Insert experience memories directly, bypassing LLM-based retain."""
ids = []
for text in texts:
mem_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, created_at)
VALUES ($1, $2, $3, 'experience', now())
""",
mem_id,
bank_id,
text,
)
ids.append(mem_id)
return ids
class TestAdaptiveBatchSplitting:
"""Verify that a failing batch is halved and retried until batch_size=1 succeeds."""
@pytest.mark.asyncio
async def test_splitting_recovers_all_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""When a batch of 2 fails, both are retried individually and succeed."""
bank_id = f"test-split-recovery-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
mem_ids = await _insert_memories(
conn,
bank_id,
[
"Alice runs marathons every spring.",
"Alice trained for six months for her last race.",
],
)
# Exhaust all 3 retries for batch=2 (calls 1-3 fail), then each batch=1 succeeds (calls 4-5)
mock_llm = _make_failing_mock_llm(fail_first_n=3)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "completed"
assert result["memories_processed"] == 2
assert result["memories_failed"] == 0
# Both memories must have consolidated_at set and consolidation_failed_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, consolidated_at, consolidation_failed_at
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'experience'
""",
bank_id,
)
assert len(rows) == 2
for row in rows:
assert row["consolidated_at"] is not None, f"Memory {row['id']} should have consolidated_at set"
assert row["consolidation_failed_at"] is None, (
f"Memory {row['id']} should NOT have consolidation_failed_at set"
)
# LLM called 5 times: 3 retries failed (batch=2) + 1 succeeded (batch=1) + 1 succeeded (batch=1)
consolidation_calls = [c for c in mock_llm.get_mock_calls() if c["scope"] == "consolidation"]
assert len(consolidation_calls) == 5
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_splitting_with_larger_batch(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A batch of 4 that always fails at size>1 resolves to 4 individual calls."""
bank_id = f"test-split-large-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
await _insert_memories(
conn,
bank_id,
[
"Bob plays chess competitively.",
"Bob won a regional chess tournament.",
"Bob practices tactics every morning.",
"Bob coaches youth chess on weekends.",
],
)
# Exhaust all 3 retries for batch=4 (calls 1-3 fail), then both batch=2 halves succeed
# (calls 4-5). This verifies that halving once is sufficient when batch=2 works.
mock_llm = _make_failing_mock_llm(fail_first_n=3)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_processed"] == 4
assert result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
assert all(r["consolidated_at"] is not None for r in rows)
assert all(r["consolidation_failed_at"] is None for r in rows)
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
class TestConsolidationFailedAt:
"""Verify that consolidation_failed_at is set — and consolidated_at is NOT — when all retries fail."""
@pytest.mark.asyncio
async def test_single_memory_permanent_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A single memory that exhausts all LLM retries gets consolidation_failed_at, not consolidated_at."""
bank_id = f"test-perm-fail-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Carol enjoys painting watercolors."])
# Always fail
mock_llm = _make_failing_mock_llm(fail_first_n=999)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_failed"] == 1
assert result["memories_processed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is None, "consolidated_at must NOT be set for a permanently failed memory"
assert row["consolidation_failed_at"] is not None, "consolidation_failed_at must be set"
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_failed_memory_excluded_from_next_run(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A memory marked consolidation_failed_at is not re-processed on the next consolidation run."""
bank_id = f"test-excluded-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Dave collects vinyl records."])
# Manually stamp consolidation_failed_at to simulate a prior failed run
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
mem_id,
)
# Even with a healthy LLM, the memory should be skipped
mock_llm = _make_always_success_mock_llm()
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
# No unconsolidated memories to pick up (consolidation_failed_at ≠ NULL, consolidated_at = NULL
# but the SELECT filters on consolidated_at IS NULL AND fact_type IN ('experience','world'))
assert result["status"] in ("no_new_memories", "completed")
if result["status"] == "completed":
assert result["memories_processed"] == 0
# Memory still has consolidation_failed_at set and consolidated_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is None
assert row["consolidation_failed_at"] is not None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_partial_batch_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""In a batch of 2, if only the first individual retry fails, the second still succeeds."""
bank_id = f"test-partial-fail-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
mem_ids = await _insert_memories(
conn,
bank_id,
[
"Eve speaks three languages fluently.",
"Eve learned Japanese in two years.",
],
)
# Exhaust 3 retries for batch=2 (calls 1-3), exhaust 3 retries for first batch=1 (calls 4-6),
# second batch=1 succeeds (call 7)
mock_llm = _make_failing_mock_llm(fail_first_n=6)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_processed"] == 2
assert result["memories_failed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
rows = {
str(r["id"]): r
for r in await conn.fetch(
"SELECT id, consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
}
# One should have failed, one should have succeeded
failed = [r for r in rows.values() if r["consolidation_failed_at"] is not None]
succeeded = [r for r in rows.values() if r["consolidated_at"] is not None]
assert len(failed) == 1
assert len(succeeded) == 1
# They must be different memories
assert str(failed[0]["id"]) != str(succeeded[0]["id"])
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
class TestRecoverConsolidation:
"""Verify the retry_failed_consolidation() method and the /consolidation/recover endpoint."""
@pytest.mark.asyncio
async def test_recover_resets_failed_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""retry_failed_consolidation resets consolidation_failed_at and consolidated_at."""
bank_id = f"test-recover-reset-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
ids = await _insert_memories(
conn,
bank_id,
[
"Frank is a competitive cyclist.",
"Frank completed the Tour de France route.",
],
)
# Mark both as failed
for mem_id in ids:
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
mem_id,
)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert result["retried_count"] == 2
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
assert all(r["consolidation_failed_at"] is None for r in rows), "consolidation_failed_at must be cleared"
assert all(r["consolidated_at"] is None for r in rows), "consolidated_at must also be cleared"
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_returns_zero_when_none_failed(self, memory_no_llm_verify: MemoryEngine, request_context):
"""retry_failed_consolidation returns 0 when no memories have failed."""
bank_id = f"test-recover-zero-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert result["retried_count"] == 0
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_then_consolidate_succeeds(self, memory_no_llm_verify: MemoryEngine, request_context):
"""After recovery, the memory is picked up by the next consolidation run."""
bank_id = f"test-recover-consolidate-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Grace is an expert rock climber."])
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
# Recover
recover_result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert recover_result["retried_count"] == 1
# Now consolidate with a healthy LLM
mock_llm = _make_always_success_mock_llm()
_inject_mock_llm(memory_no_llm_verify, mock_llm)
run_result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert run_result["memories_processed"] == 1
assert run_result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is not None, "Memory should be consolidated after recovery"
assert row["consolidation_failed_at"] is None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_endpoint_via_http(self, memory_no_llm_verify: MemoryEngine, request_context):
"""The POST /consolidation/recover endpoint returns the correct retried_count."""
import httpx
from hindsight_api.api.http import create_app
bank_id = f"test-recover-http-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
ids = await _insert_memories(
conn,
bank_id,
["Henry is a professional chef.", "Henry trained at Le Cordon Bleu."],
)
for mem_id in ids:
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
app = create_app(memory_no_llm_verify, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/v1/default/banks/{bank_id}/consolidation/recover")
assert response.status_code == 200
body = response.json()
assert body["retried_count"] == 2
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@@ -1,70 +0,0 @@
"""
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()
@@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 19
assert len(configurable) == 17
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -35,7 +35,6 @@ MODEL_MATRIX = [
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
("gemini", "gemini-3-pro-preview"),
("gemini", "gemini-3.1-flash-lite-preview"),
# Ollama models (local)
("ollama", "gemma3:12b"),
("ollama", "gemma3:1b"),
@@ -149,16 +149,6 @@ class TestLargeBatchRetain:
call_tracker = {"count": 0, "facts": 0}
async def mock_llm_call(*args, **kwargs):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
# Consolidation calls expect a _ConsolidationBatchResponse (not a raw dict),
# because consolidation does NOT use skip_validation=True.
if kwargs.get("scope") == "consolidation":
return_usage = kwargs.get("return_usage", False)
if return_usage:
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
return _ConsolidationBatchResponse()
call_tracker["count"] += 1
# Extract the content from the user message to generate proportional facts
@@ -167,7 +157,7 @@ class TestLargeBatchRetain:
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.5)
call_tracker["facts"] += len(mock_facts)
# Return a dict (parsed JSON) — fact extraction uses skip_validation=True
# Return a dict (parsed JSON) since skip_validation=True but the code expects a dict
response_dict = {"facts": mock_facts}
return_usage = kwargs.get("return_usage", False)
@@ -246,14 +236,6 @@ class TestLargeBatchRetain:
logger.info(f"Created {num_items} items with {actual_total_chars:,} chars (should trigger chunking)")
async def mock_llm_call(*args, **kwargs):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
if kwargs.get("scope") == "consolidation":
return_usage = kwargs.get("return_usage", False)
if return_usage:
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
return _ConsolidationBatchResponse()
messages = kwargs.get("messages", args[0] if args else [])
user_msg = messages[-1]["content"] if messages else ""
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.0)
@@ -1,154 +0,0 @@
"""Tests for migration g7h8i9j0k1l2 (backsweep orphaned memory_units).
Uses a dedicated pg0 instance (port 5562) so the test can control exactly
which migrations have run before inserting the orphan seed data.
"""
import asyncio
import uuid
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
def _alembic_cfg(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", _SCRIPT_LOCATION)
cfg.set_main_option("sqlalchemy.url", db_url)
cfg.set_main_option("prepend_sys_path", ".")
cfg.set_main_option("path_separator", "os")
return cfg
def _upgrade(db_url: str, revision: str) -> None:
command.upgrade(_alembic_cfg(db_url), revision)
# ---------------------------------------------------------------------------
# Fixture: fresh database at the revision just before the backsweep
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def pre_backsweep_db_url():
"""
Spin up a dedicated pg0 instance and run all migrations up to (but not
including) the backsweep revision so each test can seed orphan data and
then apply the backsweep itself.
"""
from hindsight_api.pg0 import EmbeddedPostgres
pg0 = EmbeddedPostgres(name="hindsight-backsweep-test", port=5562)
loop = asyncio.new_event_loop()
try:
url = loop.run_until_complete(pg0.ensure_running())
finally:
loop.close()
# Migrate up to the revision just before the backsweep.
_upgrade(url, "f6g7h8i9j0k1")
return url
# ---------------------------------------------------------------------------
# The test
# ---------------------------------------------------------------------------
def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url):
"""
Seed four kinds of rows then apply the backsweep migration and verify:
Rows that MUST be deleted
─────────────────────────
A. Any fact_type, bank_id missing from banks
→ Pass 1 deletes these regardless of fact_type or source links.
B. observation, bank exists, but ALL source_memory_ids are gone
→ Pass 2 deletes these.
Rows that MUST survive
──────────────────────
C. observation, bank exists, at least ONE source_memory_id still live
→ Pass 2 must not touch these.
D. Non-observation (world), bank exists, no sources (not relevant)
→ Pass 1 must not touch these (bank exists).
"""
db_url = pre_backsweep_db_url
engine = create_engine(db_url)
alive_bank = f"bank_{uuid.uuid4().hex[:8]}"
ghost_bank = f"bank_{uuid.uuid4().hex[:8]}" # never inserted into banks
# UUIDs for memory units
id_pass1_world = uuid.uuid4() # A: world unit, ghost bank
id_pass1_obs = uuid.uuid4() # A: observation, ghost bank
id_pass2_obs = uuid.uuid4() # B: observation, all sources gone
id_keep_obs = uuid.uuid4() # C: observation with one live source
id_keep_world = uuid.uuid4() # D: world unit, alive bank
id_live_source = uuid.uuid4() # live source for C
with engine.connect() as conn:
# --- banks ---
conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": alive_bank})
# --- seed memory_units ---
def insert_mu(uid, bank, fact_type, sources=None):
src_arr = "{" + ",".join(str(s) for s in (sources or [])) + "}"
conn.execute(
text(
"""
INSERT INTO memory_units
(id, bank_id, text, fact_type, source_memory_ids)
VALUES
(:id, :bank, :text, :ft, CAST(:src AS uuid[]))
"""
),
{"id": uid, "bank": bank, "text": "test", "ft": fact_type, "src": src_arr},
)
# A: ghost-bank rows (Pass 1 targets)
insert_mu(id_pass1_world, ghost_bank, "world")
insert_mu(id_pass1_obs, ghost_bank, "observation", sources=[uuid.uuid4()])
# B: observation with all-dead sources (Pass 2 target)
insert_mu(id_pass2_obs, alive_bank, "observation", sources=[uuid.uuid4(), uuid.uuid4()])
# C: observation with one live source (must survive)
insert_mu(id_live_source, alive_bank, "world")
insert_mu(id_keep_obs, alive_bank, "observation", sources=[id_live_source, uuid.uuid4()])
# D: world unit in alive bank (must survive)
insert_mu(id_keep_world, alive_bank, "world")
conn.commit()
# --- apply the backsweep ---
_upgrade(db_url, "g7h8i9j0k1l2")
# --- verify ---
with engine.connect() as conn:
def exists(uid):
return conn.execute(
text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid}
).fetchone() is not None
# Must be gone
assert not exists(id_pass1_world), "Pass 1: world unit with ghost bank should be deleted"
assert not exists(id_pass1_obs), "Pass 1: observation with ghost bank should be deleted"
assert not exists(id_pass2_obs), "Pass 2: observation with all-dead sources should be deleted"
# Must survive
assert exists(id_keep_obs), "observation with a live source must not be deleted"
assert exists(id_keep_world), "world unit in alive bank must not be deleted"
assert exists(id_live_source), "live source memory unit must not be deleted"
engine.dispose()
@@ -485,206 +485,3 @@ 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
+19 -370
View File
@@ -1,13 +1,11 @@
"""
Test retain function and chunk storage.
"""
import logging
from datetime import datetime, timedelta, timezone
import pytest
from hindsight_api import RequestContext
import logging
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
logger = logging.getLogger(__name__)
@@ -62,7 +60,7 @@ async def test_retain_with_chunks(memory, request_context):
request_context=request_context,
)
print("\n=== Recall Results (with chunks) ===")
print(f"\n=== Recall Results (with chunks) ===")
print(f"Found {len(result.results)} results")
assert len(result.results) > 0, "Should find facts about Alice"
@@ -151,7 +149,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
request_context=request_context,
)
print("\n=== Recall Results ===")
print(f"\n=== Recall Results ===")
print(f"Found {len(result.results)} facts")
# Extract the order of entities mentioned in facts
@@ -423,7 +421,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
# Verify it's the historical date, not today
assert mentioned_dt.year == 2020, f"mentioned_at should be 2020, got {mentioned_dt.year}"
print("✓ Test passed: Historical conversation correctly ingested with event_date=2020")
print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -491,15 +489,15 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
# If occurred_start is set, it means the LLM extracted it
# In this case, log it but don't fail (LLM behavior can vary)
print(f"⚠ LLM extracted occurred_start: {fact.occurred_start}")
print(" This test expects None for present-tense observations")
print(f" This test expects None for present-tense observations")
else:
print("✓ occurred_start is correctly None (not defaulted to mentioned_at)")
print(f"✓ occurred_start is correctly None (not defaulted to mentioned_at)")
if fact.occurred_end is not None:
print(f"⚠ LLM extracted occurred_end: {fact.occurred_end}")
print(" This test expects None for present-tense observations")
print(f" This test expects None for present-tense observations")
else:
print("✓ occurred_end is correctly None (not defaulted to mentioned_at)")
print(f"✓ occurred_end is correctly None (not defaulted to mentioned_at)")
# At least verify they're not equal to mentioned_at if they are set
if fact.occurred_start is not None:
@@ -515,7 +513,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
f"occurred_start={occurred_start_dt}, mentioned_at={mentioned_dt}"
)
print("✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -587,7 +585,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
else:
print(f"⚠ LLM did not extract date from context, fell back to now(): {mentioned_dt}")
print("✓ mentioned_at is always set (never None)")
print(f"✓ mentioned_at is always set (never None)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -853,8 +851,8 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
assert len(result.results) > 0, "Should recall stored facts"
print("✓ Successfully stored and retrieved facts")
print(" (Note: Metadata support depends on API implementation)")
print(f"✓ Successfully stored and retrieved facts")
print(f" (Note: Metadata support depends on API implementation)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -956,7 +954,7 @@ async def test_mixed_content_batch(memory, request_context):
short_units = len(unit_ids[0])
long_units = len(unit_ids[1])
print("✓ Mixed batch processed successfully")
print(f"✓ Mixed batch processed successfully")
print(f" Short content: {short_units} units")
print(f" Long content: {long_units} units")
@@ -1358,7 +1356,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
if truncated_chunks:
print(f" {len(truncated_chunks)} chunks were truncated due to token limit")
else:
print(" No chunks were truncated (content within limit)")
print(f" No chunks were truncated (content within limit)")
else:
print("✓ No chunks returned (may be under token limit)")
@@ -2212,10 +2210,9 @@ async def test_custom_extraction_mode():
custom guidelines while keeping structural parts intact.
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache, _get_raw_config
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
@@ -2287,7 +2284,7 @@ If the text contains both Italian and English content, extract ONLY the Italian
if found_english_only:
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
logger.warning(f" Facts: {all_facts_text}")
logger.warning(" This may indicate the LLM is not strictly following language-specific custom guidelines")
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
# Log but don't fail - LLM behavior can vary
else:
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
@@ -2318,213 +2315,6 @@ If the text contains both Italian and English content, extract ONLY the Italian
clear_config_cache()
def test_apply_strategy():
"""
Unit test for apply_strategy:
- Known strategy applies overrides on top of resolved config
- Unknown strategy returns config unchanged with a warning
- Non-hierarchical fields in a strategy are silently ignored
- entity_labels and entities_allow_free_form are overridable
"""
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
clear_config_cache()
base_config = _get_raw_config()
strategies = {
"documents": {
"retain_extraction_mode": "chunks",
"retain_chunk_size": 800,
"entities_allow_free_form": False,
},
"bad_field": {
"database_url": "should-be-ignored", # static field, not hierarchical
"retain_extraction_mode": "verbose",
},
}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
# Known strategy: overrides applied
result = apply_strategy(config_with_strategies, "documents")
assert result.retain_extraction_mode == "chunks"
assert result.retain_chunk_size == 800
assert result.entities_allow_free_form is False
# Non-hierarchical field silently ignored, hierarchical one applied
result2 = apply_strategy(config_with_strategies, "bad_field")
assert result2.retain_extraction_mode == "verbose"
assert result2.database_url == base_config.database_url # unchanged
# Unknown strategy: config returned unchanged
result3 = apply_strategy(config_with_strategies, "nonexistent")
assert result3.retain_extraction_mode == base_config.retain_extraction_mode
def test_collapse_to_verbatim_single_fact_per_chunk():
"""
Unit test for _collapse_to_verbatim:
- One fact per chunk text overridden with original chunk text
- Two facts from same chunk collapsed to one, entities merged
"""
from hindsight_api.engine.retain.fact_extraction import _collapse_to_verbatim
from hindsight_api.engine.retain.types import ChunkMetadata, ExtractedFact
chunks = [
ChunkMetadata(chunk_text="Alice went to Paris.", fact_count=1, content_index=0, chunk_index=0),
ChunkMetadata(chunk_text="Bob fixed the bug yesterday.", fact_count=2, content_index=0, chunk_index=1),
]
facts = [
ExtractedFact(fact_text="LLM paraphrase of Alice in Paris", fact_type="world", entities=["Alice", "Paris"], chunk_index=0, content_index=0),
ExtractedFact(fact_text="LLM first fact about Bob", fact_type="world", entities=["Bob"], chunk_index=1, content_index=0),
ExtractedFact(fact_text="LLM second fact about bug", fact_type="world", entities=["bug"], chunk_index=1, content_index=0),
]
result = _collapse_to_verbatim(facts, chunks)
assert len(result) == 2, "Should produce exactly one fact per chunk"
# Chunk 0: text overridden with original chunk text
assert result[0].fact_text == "Alice went to Paris.", "Text must be the raw chunk text"
assert result[0].entities == ["Alice", "Paris"]
# Chunk 1: collapsed to one fact, entities merged from both LLM facts
assert result[1].fact_text == "Bob fixed the bug yesterday.", "Text must be the raw chunk text"
assert "Bob" in result[1].entities
assert "bug" in result[1].entities
def test_chunks_extraction_mode():
"""
Unit test for chunks mode: no LLM, chunks stored as-is, zero token usage.
"""
import asyncio
import os
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "chunks"
clear_config_cache()
contents = [
RetainContent(
content="Alice joined the infrastructure team on March 5, 2024.",
event_date=datetime(2024, 3, 10, tzinfo=timezone.utc),
entities=[{"text": "Alice"}, {"text": "infrastructure team"}],
),
RetainContent(content="Bob fixed the critical bug in the payment service."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # Must not be called
agent_name="TestAgent",
config=_get_raw_config(),
)
)
# One fact per chunk (both contents fit in one chunk each)
assert len(facts) == len(chunks) == 2
# Text preserved exactly
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
# No LLM-extracted entities (user-provided entities handled downstream)
assert facts[0].entities == []
assert facts[1].entities == []
# Zero token usage
assert usage.total_tokens == 0
logger.info("✓ chunks mode: no LLM call, chunks stored as-is, zero token usage")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_verbatim_extraction_mode():
"""
Integration test for verbatim extraction mode.
Verifies that:
1. Each chunk produces exactly one fact
2. The fact text is the original chunk text, not a paraphrase
3. Entities are still extracted by the LLM
4. Temporal info (occurred_start) is still extracted
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "verbatim"
clear_config_cache()
text = (
"Alice joined the infrastructure team on March 5, 2024. "
"She holds a CKA certification and has 5 years of Kubernetes experience."
)
llm_config = LLMConfig.for_memory()
contents = [RetainContent(content=text, event_date=datetime(2024, 3, 10, tzinfo=timezone.utc), context="onboarding notes")]
facts, chunks, _ = await extract_facts_from_contents(
contents=contents,
llm_config=llm_config,
agent_name="TestAgent",
config=_get_raw_config(),
)
logger.info(f"Verbatim mode extracted {len(facts)} facts from {len(chunks)} chunks")
for i, f in enumerate(facts):
logger.info(f" fact[{i}]: {f.fact_text!r} entities={f.entities}")
# One fact per chunk
assert len(facts) == len(chunks), "Verbatim mode must produce exactly one fact per chunk"
# Text must match the original chunk exactly
for fact, chunk in zip(facts, chunks):
assert fact.fact_text == chunk.chunk_text, (
f"fact_text must equal original chunk text.\n"
f" expected: {chunk.chunk_text!r}\n"
f" got: {fact.fact_text!r}"
)
# Entities should still be extracted
all_entities = [e for f in facts for e in f.entities]
assert any("alice" in e.lower() for e in all_entities), (
f"Expected entity 'Alice' to be extracted. Entities: {all_entities}"
)
logger.info("✓ Verbatim mode preserves chunk text and still extracts entities")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
"""
@@ -2559,7 +2349,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
)
assert len(result) > 0, "Should have retained content"
print("\n=== Retained content with tags ===")
print(f"\n=== Retained content with tags ===")
# Retrieve the document
doc = await memory.get_document(
@@ -2590,7 +2380,6 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
def test_retain_mission_injected_into_prompt():
"""Test that retain_mission is injected as a FOCUS section into any extraction mode."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
spec = "Focus on technical decisions and architecture choices only."
@@ -2616,7 +2405,6 @@ def test_retain_mission_injected_into_prompt():
def test_retain_mission_absent_when_not_set():
"""Test that no FOCUS section appears when retain_mission is not set."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
config = MagicMock()
@@ -2633,7 +2421,6 @@ def test_retain_mission_absent_when_not_set():
def test_retain_mission_config_loaded_from_env():
"""Test that retain_mission is loaded from env and is a configurable field."""
import os
from hindsight_api.config import HindsightConfig, _get_raw_config, clear_config_cache
original = os.getenv("HINDSIGHT_API_RETAIN_MISSION")
@@ -2649,141 +2436,3 @@ def test_retain_mission_config_loaded_from_env():
else:
os.environ["HINDSIGHT_API_RETAIN_MISSION"] = original
clear_config_cache()
def test_strategy_overrides_extraction_mode_for_chunks():
"""
Unit test: a named strategy with retain_extraction_mode=chunks causes
extract_facts_from_contents to skip the LLM and return verbatim chunks.
"""
import asyncio
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
clear_config_cache()
base_config = _get_raw_config()
# Build a config that has a strategy overriding to chunks
strategies = {"fast": {"retain_extraction_mode": "chunks"}}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
strategy_config = apply_strategy(config_with_strategies, "fast")
assert strategy_config.retain_extraction_mode == "chunks"
contents = [
RetainContent(content="Alice deployed the new API on Monday."),
RetainContent(content="Bob reviewed the pull request."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # chunks must not call the LLM
agent_name="TestAgent",
config=strategy_config,
)
)
assert len(facts) == 2
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
assert usage.total_tokens == 0
logger.info("✓ strategy with chunks mode: no LLM, verbatim chunks, zero tokens")
def test_retain_request_per_item_strategy_field():
"""
Unit test: MemoryItem accepts a strategy field; items with different strategies
are grouped correctly by per-item strategy.
"""
from hindsight_api.api.http import RetainRequest
request = RetainRequest.model_validate(
{
"items": [
{"content": "Alice joined.", "strategy": "fast"},
{"content": "Bob left.", "strategy": "detailed"},
{"content": "Carol arrived."}, # no strategy — falls back to bank default
],
}
)
assert request.items[0].strategy == "fast"
assert request.items[1].strategy == "detailed"
assert request.items[2].strategy is None
# Simulate grouping logic from api_retain handler
strategy_groups: dict = {}
for item in request.items:
strategy_groups.setdefault(item.strategy, []).append(item.content)
assert set(strategy_groups.keys()) == {"fast", "detailed", None}
assert strategy_groups["fast"] == ["Alice joined."]
assert strategy_groups["detailed"] == ["Bob left."]
assert strategy_groups[None] == ["Carol arrived."]
logger.info("✓ per-item strategy grouping works correctly")
@pytest.mark.asyncio
async def test_named_strategy_applied_end_to_end(memory, request_context):
"""
Integration test: a named strategy stored in bank config is actually applied
during retain_batch_async.
Regression test for the bug where strategy was passed through the HTTP layer
but the extraction mode override was silently ignored, always using the bank
default (e.g. 'concise') instead of the strategy's override (e.g. 'chunks').
"""
from hindsight_api.config_resolver import ConfigResolver
bank_id = f"test_strategy_e2e_{datetime.now(timezone.utc).timestamp()}"
try:
# Seed the bank so the row exists before we write config to it
# (update_bank_config is a plain UPDATE — it silently no-ops on missing rows)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "seed"}],
request_context=request_context,
)
# Now configure the bank with a named strategy that overrides to chunks
await memory._config_resolver.update_bank_config(
bank_id,
{
"retain_extraction_mode": "concise", # bank default
"retain_strategies": {
"chunks": {"retain_extraction_mode": "chunks"},
},
},
request_context,
)
contents = [{"content": "Alice deployed the new API on Monday."}]
# Retain using the named strategy
unit_ids_by_content, usage = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
strategy="chunks",
request_context=request_context,
return_usage=True,
)
# chunks produces exactly one fact per chunk (verbatim) and calls no LLM
assert usage.total_tokens == 0, f"chunks should use zero LLM tokens, got {usage.total_tokens}"
assert len(unit_ids_by_content) == 1
assert len(unit_ids_by_content[0]) == 1, "chunks should produce exactly one fact per content item"
# Verify the stored fact is the verbatim content
facts = await memory.recall_async(bank_id, "Alice", request_context=request_context)
assert any("Alice" in f.text for f in facts.results), "Verbatim content should be retrievable"
logger.info("✓ named strategy 'chunks' with chunks applied end-to-end: no LLM, verbatim storage")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.4.19"
version = "0.4.17"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.19"
version = "0.4.17"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
-7
View File
@@ -601,13 +601,6 @@ 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> {
+1 -29
View File
@@ -717,13 +717,6 @@ 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<()> {
@@ -743,30 +736,9 @@ 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, --retain-mission, --observations-mission, or other flags".to_string()));
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
}
let spinner = if output_format == OutputFormat::Pretty {
+3 -4
View File
@@ -149,12 +149,11 @@ 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() && is_active.is_none() {
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
if name.is_none() && content.is_none() {
anyhow::bail!("At least one of --name or --content must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
@@ -166,7 +165,7 @@ pub fn update(
let request = types::UpdateDirectiveRequest {
name,
content,
is_active,
is_active: None,
priority: None,
tags: None,
};
-3
View File
@@ -363,9 +363,6 @@ 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)
+6 -34
View File
@@ -9,7 +9,7 @@ use crate::output::{self, OutputFormat};
use crate::ui;
// Import types from generated client
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
use serde::Deserialize;
use serde_json;
@@ -43,16 +43,6 @@ 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,
@@ -260,8 +250,6 @@ 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<()> {
@@ -292,8 +280,8 @@ pub fn recall(
trace,
query_timestamp: None,
include,
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
tags: None,
tags_match: TagsMatch::Any,
tag_groups: None,
};
@@ -324,9 +312,6 @@ 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<()> {
@@ -347,28 +332,16 @@ 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,
include: None,
response_schema,
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
tags: None,
tags_match: TagsMatch::Any,
tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
};
let response = client.reflect(agent_id, &request, verbose);
@@ -417,7 +390,6 @@ pub fn retain(
entities: None,
tags: None,
observation_scopes: None,
strategy: None,
};
let request = RetainRequest {
@@ -272,55 +272,6 @@ 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);
+8 -72
View File
@@ -310,34 +310,6 @@ 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)
@@ -415,14 +387,6 @@ 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)
@@ -448,18 +412,6 @@ 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
@@ -726,15 +678,6 @@ 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)]
@@ -781,10 +724,6 @@ enum DirectiveCommands {
/// New content
#[arg(long)]
content: Option<String>,
/// Enable or disable the directive
#[arg(long)]
is_active: Option<bool>,
},
/// Delete a directive
@@ -882,8 +821,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, 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::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::ResetConfig { bank_id, yes } => {
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
@@ -898,11 +837,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, 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::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::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::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::Retain { bank_id, content, doc_id, context, r#async } => {
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
@@ -991,9 +930,6 @@ 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
@@ -1007,8 +943,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, is_active } => {
commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, 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::Delete { bank_id, directive_id, yes } => {
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
+1 -128
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.4.19
version: 0.4.17
servers:
- url: /
paths:
@@ -2106,46 +2106,6 @@ paths:
summary: Clear all observations
tags:
- Banks
/v1/default/banks/{bank_id}/consolidation/recover:
post:
description: Reset all memories that were permanently marked as failed during
consolidation (after exhausting all LLM retries and adaptive batch splitting)
so they are picked up again on the next consolidation run. Does not delete
any observations.
operationId: recover_consolidation
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/RecoverConsolidationResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Recover failed consolidation
tags:
- Banks
/v1/default/banks/{bank_id}/memories/{memory_id}/observations:
delete:
description: Delete all observations derived from a specific memory and reset
@@ -4054,9 +4014,6 @@ components:
type: array
observation_scopes:
$ref: '#/components/schemas/ObservationScopes'
strategy:
nullable: true
type: string
required:
- content
title: MemoryItem
@@ -4074,13 +4031,6 @@ 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:
@@ -4096,13 +4046,6 @@ 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:
@@ -4129,13 +4072,6 @@ 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:
@@ -4190,13 +4126,6 @@ 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
@@ -4204,26 +4133,6 @@ 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.
@@ -4537,17 +4446,6 @@ components:
- id
- text
title: RecallResult
RecoverConsolidationResponse:
description: Response model for recovering failed consolidation.
example:
retried_count: 42
properties:
retried_count:
title: Retried Count
type: integer
required:
- retried_count
title: RecoverConsolidationResponse
ReflectBasedOn:
description: "Evidence the response is based on: memories, mental models, and\
\ directives."
@@ -4732,26 +4630,6 @@ 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
@@ -4917,11 +4795,6 @@ components:
operation_id:
nullable: true
type: string
operation_ids:
items:
type: string
nullable: true
type: array
usage:
$ref: '#/components/schemas/TokenUsage'
required:
+1 -123
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -1023,128 +1023,6 @@ func (a *BanksAPIService) ListBanksExecute(r ApiListBanksRequest) (*BankListResp
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiRecoverConsolidationRequest struct {
ctx context.Context
ApiService *BanksAPIService
bankId string
authorization *string
}
func (r ApiRecoverConsolidationRequest) Authorization(authorization string) ApiRecoverConsolidationRequest {
r.authorization = &authorization
return r
}
func (r ApiRecoverConsolidationRequest) Execute() (*RecoverConsolidationResponse, *http.Response, error) {
return r.ApiService.RecoverConsolidationExecute(r)
}
/*
RecoverConsolidation Recover failed consolidation
Reset all memories that were permanently marked as failed during consolidation (after exhausting all LLM retries and adaptive batch splitting) so they are picked up again on the next consolidation run. Does not delete any observations.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiRecoverConsolidationRequest
*/
func (a *BanksAPIService) RecoverConsolidation(ctx context.Context, bankId string) ApiRecoverConsolidationRequest {
return ApiRecoverConsolidationRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return RecoverConsolidationResponse
func (a *BanksAPIService) RecoverConsolidationExecute(r ApiRecoverConsolidationRequest) (*RecoverConsolidationResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *RecoverConsolidationResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "BanksAPIService.RecoverConsolidation")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/consolidation/recover"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiResetBankConfigRequest struct {
ctx context.Context
ApiService *BanksAPIService
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.4.19
// APIClient manages communication with the Hindsight HTTP API API v0.4.17
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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