Compare commits

..
2 Commits
Author SHA1 Message Date
Nicolò Boschi 79f683cdf9 fix 2026-02-18 13:44:37 +01:00
Nicolò Boschi ce74b1fc56 feat: add iris as file parser 2026-02-18 12:06:04 +01:00
369 changed files with 4746 additions and 13157 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
HINDSIGHT_API_LLM_MODEL=o3-mini
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Example: Anthropic Claude configuration
+1 -15
View File
@@ -46,10 +46,6 @@ jobs:
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
# Publish in order (client and api first, then hindsight-all which depends on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -81,12 +77,6 @@ jobs:
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
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -98,7 +88,6 @@ jobs:
hindsight/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
retention-days: 1
release-typescript-client:
@@ -279,14 +268,11 @@ jobs:
- name: Build
run: npm run build --workspace=hindsight-control-plane
- name: Verify standalone build
run: test -f hindsight-control-plane/standalone/server.js || (echo 'standalone/server.js missing - build failed' && exit 1)
- name: Publish to npm
working-directory: ./hindsight-control-plane
run: |
set +e
OUTPUT=$(npm publish --access public --ignore-scripts 2>&1)
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
+68 -410
View File
@@ -171,9 +171,9 @@ jobs:
test-rust-cli:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
@@ -181,12 +181,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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 Rust
uses: dtolnay/rust-toolchain@stable
@@ -233,46 +227,25 @@ jobs:
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -367,21 +340,12 @@ jobs:
# Only test slim variants to save disk space (they're much smaller)
# Slim variants require external embedding providers
- name: Setup GCP credentials for smoke test
if: matrix.variant == 'slim'
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: Smoke test - verify container starts
if: matrix.variant == 'slim'
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID: ${{ env.HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID }}
HINDSIGHT_API_EMBEDDINGS_PROVIDER: cohere
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_PROVIDER: openai
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_RERANKER_PROVIDER: cohere
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
@@ -389,13 +353,14 @@ jobs:
test-api:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
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
@@ -403,12 +368,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -455,9 +414,9 @@ jobs:
test-python-client:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -466,12 +425,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -499,46 +452,25 @@ jobs:
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -558,9 +490,9 @@ jobs:
test-typescript-client:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -569,12 +501,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -607,46 +533,25 @@ jobs:
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -666,9 +571,9 @@ jobs:
test-rust-client:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -677,12 +582,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -714,46 +613,25 @@ jobs:
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -773,9 +651,9 @@ jobs:
test-go-client:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -784,12 +662,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -815,56 +687,31 @@ jobs:
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Build Go client
working-directory: ./hindsight-clients/go
run: go build ./...
- name: Run Go client tests
working-directory: ./hindsight-clients/go
run: go test -v -tags=integration
@@ -875,120 +722,12 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-openclaw-integration:
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
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@v4
- 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@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Install embed dependencies
working-directory: ./hindsight-embed
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
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: Install openclaw integration dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- 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 openclaw integration tests
working-directory: ./hindsight-integrations/openclaw
run: npm run test:integration
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-integration:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
@@ -996,12 +735,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -1049,22 +782,21 @@ jobs:
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -1081,35 +813,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-crewai-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build crewai integration
working-directory: ./hindsight-integrations/crewai
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/crewai
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/crewai
run: uv run pytest tests -v
test-litellm-integration:
runs-on: ubuntu-latest
@@ -1142,21 +845,15 @@ jobs:
test-embed:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -1192,25 +889,19 @@ jobs:
test-hindsight-all:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
# For test_server_integration.py compatibility
HINDSIGHT_LLM_PROVIDER: vertexai
HINDSIGHT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_LLM_PROVIDER: groq
HINDSIGHT_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- 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@v5
with:
@@ -1247,9 +938,9 @@ jobs:
runs-on: ubuntu-latest
needs: test-rust-cli
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
@@ -1257,12 +948,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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: Download CLI artifact
uses: actions/download-artifact@v4
with:
@@ -1305,46 +990,25 @@ jobs:
npm ci --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-clients/typescript
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading reranker 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_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
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
for i in {1..60}; 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"
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
@@ -1366,9 +1030,9 @@ jobs:
test-upgrade:
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_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
@@ -1377,12 +1041,6 @@ jobs:
with:
fetch-depth: 0 # Full history needed for git clone of tags
- 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: Fetch tags
run: git fetch --tags
+1 -1
View File
@@ -317,7 +317,7 @@ npm install
Required env vars:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., o3-mini, claude-sonnet-4-20250514)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
-10
View File
@@ -170,11 +170,6 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
@@ -326,11 +321,6 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
+10 -26
View File
@@ -13,9 +13,9 @@
# target - Optional: 'cp-only' for control plane, otherwise assumes API image (default: api)
#
# Environment variables:
# HINDSIGHT_API_LLM_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: openai)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: gpt-4o-mini)
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER - Embeddings provider (optional, for slim images: openai, cohere, tei)
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY - OpenAI API key for embeddings (optional)
# HINDSIGHT_API_RERANKER_PROVIDER - Reranker provider (optional, for slim images: cohere, tei)
@@ -34,7 +34,7 @@
# ./docker/test-image.sh hindsight-control-plane:test cp-only
#
# # Test slim image with external providers
# export HINDSIGHT_API_LLM_API_KEY=sk_xxx
# export GROQ_API_KEY=gsk_xxx
# export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
# export HINDSIGHT_API_RERANKER_PROVIDER=cohere
@@ -60,8 +60,8 @@ IMAGE="${1:-}"
TARGET="${2:-api}"
TIMEOUT="${SMOKE_TEST_TIMEOUT:-120}"
CONTAINER_NAME="${SMOKE_TEST_CONTAINER_NAME:-hindsight-smoke-test}"
LLM_PROVIDER="${HINDSIGHT_API_LLM_PROVIDER:-openai}"
LLM_MODEL="${HINDSIGHT_API_LLM_MODEL:-gpt-4o-mini}"
LLM_PROVIDER="${HINDSIGHT_API_LLM_PROVIDER:-groq}"
LLM_MODEL="${HINDSIGHT_API_LLM_MODEL:-llama-3.3-70b-versatile}"
# Validate arguments
if [ -z "$IMAGE" ]; then
@@ -88,9 +88,9 @@ else
fi
# Check for required environment variables
if [ "$NEEDS_LLM" = true ] && [ "$LLM_PROVIDER" != "vertexai" ] && [ -z "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
echo -e "${RED}Error: HINDSIGHT_API_LLM_API_KEY environment variable is required for API/standalone images${NC}"
echo "Set it with: export HINDSIGHT_API_LLM_API_KEY=your-api-key"
if [ "$NEEDS_LLM" = true ] && [ -z "${GROQ_API_KEY:-}" ]; then
echo -e "${RED}Error: GROQ_API_KEY environment variable is required for API/standalone images${NC}"
echo "Set it with: export GROQ_API_KEY=your-api-key"
exit 2
fi
@@ -123,25 +123,9 @@ else
# Build docker run command with required and optional env vars
DOCKER_CMD="docker run -d --name $CONTAINER_NAME"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_PROVIDER=$LLM_PROVIDER"
if [ -n "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${HINDSIGHT_API_LLM_API_KEY}"
fi
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${GROQ_API_KEY}"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
# Add Vertex AI config if provider is vertexai
if [ "$LLM_PROVIDER" = "vertexai" ]; then
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -v ${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY}:/tmp/gcp-credentials.json:ro"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json"
fi
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID}"
fi
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_REGION:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_REGION=${HINDSIGHT_API_LLM_VERTEXAI_REGION}"
fi
fi
# Add optional embeddings provider config
if [ -n "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_PROVIDER=${HINDSIGHT_API_EMBEDDINGS_PROVIDER}"
+9 -5
View File
@@ -6,17 +6,24 @@
# It expects API keys to be set in environment variables.
#
# Usage:
# export GROQ_API_KEY=gsk_xxx
# export OPENAI_API_KEY=sk-xxx
# export COHERE_API_KEY=xxx
# ./docker/test-slim-local.sh
#
# Or inline:
# OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
# GROQ_API_KEY=gsk_xxx OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
#
set -euo pipefail
# Check for required API keys
if [ -z "${GROQ_API_KEY:-}" ]; then
echo "❌ Error: GROQ_API_KEY environment variable is required"
echo "Set it with: export GROQ_API_KEY=gsk_xxx"
exit 1
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "❌ Error: OPENAI_API_KEY environment variable is required"
echo "Set it with: export OPENAI_API_KEY=sk-xxx"
@@ -34,10 +41,7 @@ IMAGE="${1:-hindsight-slim:test}"
echo "Testing image: $IMAGE"
echo ""
# Set up LLM and external providers
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
# Set up external providers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.13
appVersion: "0.4.13"
version: 0.4.11
appVersion: "0.4.11"
keywords:
- ai
- memory
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.13"
__version__ = "0.4.11"
+7 -47
View File
@@ -74,7 +74,7 @@ from hindsight_api.config import get_config
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
@@ -97,12 +97,6 @@ class ChunkIncludeOptions(BaseModel):
max_tokens: int = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)")
class SourceFactsIncludeOptions(BaseModel):
"""Options for including source facts for observation-type results."""
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
class IncludeOptions(BaseModel):
"""Options for including additional data in recall results."""
@@ -113,10 +107,6 @@ class IncludeOptions(BaseModel):
chunks: ChunkIncludeOptions | None = Field(
default=None, description="Include raw chunks. Set to {} to enable, null to disable (default: disabled)."
)
source_facts: SourceFactsIncludeOptions | None = Field(
default=None,
description="Include source facts for observation-type results. Set to {} to enable, null to disable (default: disabled).",
)
class RecallRequest(BaseModel):
@@ -199,9 +189,6 @@ class RecallResult(BaseModel):
metadata: dict[str, str] | None = None # User-defined metadata
chunk_id: str | None = None # Chunk this fact was extracted from
tags: list[str] | None = None # Visibility scope tags
source_fact_ids: list[str] | None = (
None # IDs of source facts (observation type only, when source_facts is enabled)
)
class EntityObservationResponse(BaseModel):
@@ -353,9 +340,6 @@ class RecallResponse(BaseModel):
default=None, description="Entity states for entities mentioned in results"
)
chunks: dict[str, ChunkData] | None = Field(default=None, description="Chunks for facts, keyed by chunk_id")
source_facts: dict[str, RecallResult] | None = Field(
default=None, description="Source facts for observation-type results, keyed by fact ID"
)
class EntityInput(BaseModel):
@@ -429,6 +413,7 @@ class RetainRequest(BaseModel):
},
],
"async": False,
"document_tags": ["user_a", "user_b"],
}
}
)
@@ -441,8 +426,7 @@ class RetainRequest(BaseModel):
)
document_tags: list[str] | None = Field(
default=None,
description="Deprecated. Use item-level tags instead.",
deprecated=True,
description="Tags applied to all items in this request. These are merged with any item-level tags.",
)
@@ -1975,10 +1959,6 @@ def _register_routes(app: FastAPI):
include_chunks = request.include.chunks is not None
max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192
# Determine source facts inclusion settings
include_source_facts = request.include.source_facts is not None
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
pre_recall = time.time() - handler_start
# Run recall with tracing (record metrics)
with metrics.record_operation(
@@ -1997,16 +1977,14 @@ def _register_routes(app: FastAPI):
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
)
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
def _fact_to_result(fact: "MemoryFact") -> RecallResult:
return RecallResult(
recall_results = [
RecallResult(
id=fact.id,
text=fact.text,
type=fact.fact_type,
@@ -2018,10 +1996,9 @@ def _register_routes(app: FastAPI):
document_id=fact.document_id,
chunk_id=fact.chunk_id,
tags=fact.tags,
source_fact_ids=fact.source_fact_ids,
)
recall_results = [_fact_to_result(fact) for fact in core_result.results]
for fact in core_result.results
]
# Convert chunks from engine to HTTP API format
chunks_response = None
@@ -2049,19 +2026,11 @@ def _register_routes(app: FastAPI):
],
)
# Convert source facts dict to API format
source_facts_response = None
if core_result.source_facts:
source_facts_response = {
fact_id: _fact_to_result(fact) for fact_id, fact in core_result.source_facts.items()
}
response = RecallResponse(
results=recall_results,
trace=core_result.trace,
entities=entities_response,
chunks=chunks_response,
source_facts=source_facts_response,
)
handler_duration = time.time() - handler_start
@@ -3485,9 +3454,6 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
# Get resolved config from config resolver
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
@@ -3523,9 +3489,6 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
# Update config via config resolver (validates configurable fields and permissions)
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
@@ -3563,9 +3526,6 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
# Reset config via config resolver
await app.state.memory._config_resolver.reset_bank_config(bank_id)
+7 -6
View File
@@ -78,9 +78,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
If False, only expose bank-scoped tools without bank_id parameters.
Returns:
Configured FastMCP server instance
Configured FastMCP server instance with stateless_http enabled
"""
mcp = FastMCP("hindsight-mcp-server")
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
# Configure and register tools using shared module
config = MCPToolsConfig(
@@ -210,9 +211,9 @@ class MCPMiddleware:
else:
# Create servers internally (for direct construction / tests)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=True)
self.single_bank_app = self.single_bank_server.http_app(path="/")
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
@@ -378,9 +379,9 @@ def create_mcp_servers(memory: MemoryEngine):
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
"""
multi_bank_server = create_mcp_server(memory, multi_bank=True)
multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=True)
multi_bank_app = multi_bank_server.http_app(path="/")
single_bank_server = create_mcp_server(memory, multi_bank=False)
single_bank_app = single_bank_server.http_app(path="/", stateless_http=True)
single_bank_app = single_bank_server.http_app(path="/")
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
+6 -3
View File
@@ -233,6 +233,8 @@ ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# OpenTelemetry tracing configuration
@@ -314,18 +316,18 @@ DEFAULT_LLM_PROVIDER = "openai"
# Provider-specific default models
PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"openai": "o3-mini",
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
"vertexai": "gemini-2.0-flash-001",
"openai-codex": "gpt-5.2-codex",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
DEFAULT_LLM_MODEL = "o3-mini" # Fallback if provider not in table
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -387,6 +389,7 @@ DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp",
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Retain settings
@@ -16,7 +16,6 @@ from typing import Any
import asyncpg
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext
@@ -150,8 +149,8 @@ class ConfigResolver:
try:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
f"""
SELECT config FROM {fq_table("banks")} WHERE bank_id = $1
"""
SELECT config FROM banks WHERE bank_id = $1
""",
bank_id,
)
@@ -242,8 +241,8 @@ class ConfigResolver:
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
"""
UPDATE banks
SET config = config || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
@@ -263,9 +262,9 @@ class ConfigResolver:
"""
async with self.pool.acquire() as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = '{{}}'::jsonb,
"""
UPDATE banks
SET config = '{}'::jsonb,
updated_at = now()
WHERE bank_id = $1
""",
@@ -18,8 +18,6 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
from ...config import get_config
from ..memory_engine import fq_table
from ..retain import embedding_utils
@@ -33,22 +31,10 @@ if TYPE_CHECKING:
from ...api.http import RequestContext
from ..memory_engine import MemoryEngine
from ..response_models import MemoryFact, RecallResult
logger = logging.getLogger(__name__)
class _ConsolidationAction(BaseModel):
action: str # "update" | "create"
text: str
reason: str = ""
learning_id: str | None = None # required for "update" actions
class _ConsolidationResponse(BaseModel):
actions: list[_ConsolidationAction]
class ConsolidationPerfLog:
"""Performance logging for consolidation operations."""
@@ -459,7 +445,8 @@ async def _process_memory(
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
recall_result = await _find_related_observations(
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
@@ -475,7 +462,7 @@ async def _process_memory(
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
recall_result=recall_result,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
@@ -496,7 +483,7 @@ async def _process_memory(
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=recall_result.results,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
@@ -550,7 +537,7 @@ async def _execute_update_action(
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
observations: list["MemoryFact"],
observations: list[dict[str, Any]],
source_fact_tags: list[str] | None = None,
source_occurred_start: datetime | None = None,
source_occurred_end: datetime | None = None,
@@ -579,27 +566,28 @@ async def _execute_update_action(
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
# Find the observation
model = next((m for m in observations if m.id == learning_id), None)
model = next((m for m in observations if str(m["id"]) == learning_id), None)
if not model:
return {"action": "skipped", "reason": "learning_not_found"}
# Build history entry (history is fetched fresh from DB on update to avoid stale state)
history = [
# Build history entry
history = list(model.get("history", []))
history.append(
{
"previous_text": model.text,
"previous_text": model["text"],
"changed_at": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"source_memory_id": str(memory_id),
}
]
)
# Update source_memory_ids
source_ids = list(model.source_fact_ids or [])
source_ids = list(model.get("source_memory_ids", []))
source_ids.append(memory_id)
# SECURITY: Merge source fact's tags into existing observation tags
# This ensures all contributors can see the observation they contributed to
existing_tags = set(model.tags or [])
existing_tags = set(model.get("tags", []) or [])
source_tags = set(source_fact_tags or [])
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
if source_tags and source_tags != existing_tags:
@@ -735,12 +723,13 @@ async def _create_memory_links(
async def _find_related_observations(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
tags: list[str] | None = None,
) -> "RecallResult":
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using optimized recall.
@@ -785,51 +774,96 @@ async def _find_related_observations(
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation
_quiet=True, # Suppress logging
)
finally:
if recall_span:
recall_span.end()
return recall_result
# If no observations returned, return empty list
if not recall_result.results:
return []
# Batch fetch all observations in a single query (no artificial limit)
observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
def _build_observations_for_llm(
observations: "list[MemoryFact]",
source_facts: "dict[str, MemoryFact]",
) -> list[dict[str, Any]]:
"""Serialize MemoryFact observations into dicts for the consolidation LLM prompt."""
obs_list = []
for obs in observations:
obs_data: dict[str, Any] = {
"id": obs.id,
"text": obs.text,
"proof_count": len(obs.source_fact_ids or []) or 1,
"tags": obs.tags or [],
}
if obs.occurred_start:
obs_data["occurred_start"] = obs.occurred_start
if obs.occurred_end:
obs_data["occurred_end"] = obs.occurred_end
if obs.mentioned_at:
obs_data["mentioned_at"] = obs.mentioned_at
source_memories = [
{"text": sf.text, "occurred_start": sf.occurred_start}
for sid in (obs.source_fact_ids or [])[:3]
if (sf := source_facts.get(sid)) is not None
]
if source_memories:
obs_data["source_memories"] = source_memories
obs_list.append(obs_data)
return obs_list
rows = await conn.fetch(
f"""
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
occurred_start, occurred_end, mentioned_at
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
""",
observation_ids,
bank_id,
)
# Build results list preserving recall order
id_to_row = {row["id"]: row for row in rows}
results = []
for obs in recall_result.results:
obs_id = uuid.UUID(obs.id)
if obs_id not in id_to_row:
continue
row = id_to_row[obs_id]
history = row["history"]
if isinstance(history, str):
history = json.loads(history)
elif history is None:
history = []
# Fetch source memories to include their text and dates
source_memory_ids = row["source_memory_ids"] or []
source_memories = []
if source_memory_ids:
source_rows = await conn.fetch(
f"""
SELECT text, occurred_start, occurred_end, mentioned_at, event_date
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2
ORDER BY created_at ASC
LIMIT 5
""",
source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
bank_id,
)
for src_row in source_rows:
source_memories.append(
{
"text": src_row["text"],
"occurred_start": src_row["occurred_start"],
"occurred_end": src_row["occurred_end"],
"mentioned_at": src_row["mentioned_at"],
"event_date": src_row["event_date"],
}
)
results.append(
{
"id": row["id"],
"text": row["text"],
"proof_count": row["proof_count"] or 1,
"tags": row["tags"] or [],
"source_memories": source_memories,
"occurred_start": row["occurred_start"],
"occurred_end": row["occurred_end"],
"mentioned_at": row["mentioned_at"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
)
return results
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
recall_result: "RecallResult",
observations: list[dict[str, Any]],
mission: str,
) -> list[dict[str, Any]]:
"""
@@ -850,11 +884,40 @@ async def _consolidate_with_llm(
- {"action": "create", "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
observations = recall_result.results
source_facts = recall_result.source_facts or {}
# Format observations as JSON with source memories and dates
if observations:
obs_list = _build_observations_for_llm(observations, source_facts)
obs_list = []
for obs in observations:
obs_data = {
"id": str(obs["id"]),
"text": obs["text"],
"proof_count": obs["proof_count"],
"tags": obs["tags"],
"created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
"updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
}
# Include temporal info if available
if obs.get("occurred_start"):
obs_data["occurred_start"] = obs["occurred_start"].isoformat()
if obs.get("occurred_end"):
obs_data["occurred_end"] = obs["occurred_end"].isoformat()
if obs.get("mentioned_at"):
obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
# Include source memories (up to 3 for brevity)
if obs.get("source_memories"):
obs_data["source_memories"] = [
{
"text": sm["text"],
"event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
"occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
}
for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
]
obs_list.append(obs_data)
observations_text = json.dumps(obs_list, indent=2)
else:
observations_text = "[]"
@@ -879,12 +942,42 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
{"role": "user", "content": user_prompt},
]
response: _ConsolidationResponse = await memory_engine._consolidation_llm_config.call(
messages=messages,
response_format=_ConsolidationResponse,
scope="consolidation",
)
return [a.model_dump() for a in response.actions]
try:
result = await memory_engine._consolidation_llm_config.call(
messages=messages,
skip_validation=True, # Raw JSON response
scope="consolidation",
)
# Parse JSON response - should be an array
if isinstance(result, str):
# Strip markdown code fences (some models wrap JSON in ```json ... ```)
clean = result.strip()
if clean.startswith("```"):
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
if clean.endswith("```"):
clean = clean[:-3]
clean = clean.strip()
result = json.loads(clean)
# Ensure result is a list
if isinstance(result, list):
return result
# Handle legacy single-action format for backward compatibility
if isinstance(result, dict):
if result.get("related_ids") and result.get("consolidated_text"):
# Convert old format to new format
return [
{
"action": "update",
"learning_id": result["related_ids"][0],
"text": result["consolidated_text"],
"reason": result.get("reason", ""),
}
]
return []
return []
except Exception as e:
logger.warning(f"Error in consolidation LLM call: {e}")
return []
async def _create_observation_directly(
@@ -2,7 +2,7 @@
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
You must output a JSON object with an "actions" array. The "text" field within each action should use markdown formatting (headers, lists, bold, etc.) for clarity and readability.
You must output ONLY valid JSON with no markdown code blocks or additional text. However, the "text" field within each observation should use markdown formatting (headers, lists, bold, etc.) for clarity and readability.
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
@@ -58,6 +58,7 @@ Each observation includes:
- text: the observation content
- proof_count: number of supporting memories
- tags: visibility scope (handled automatically)
- created_at/updated_at: when observation was created/modified
- occurred_start/occurred_end: temporal range of source facts
- source_memories: array of supporting facts with their text and dates
@@ -68,15 +69,15 @@ Instructions:
4. Compare with observations:
- Same topic → UPDATE with learning_id
- New topic → CREATE new observation
- Purely ephemeral → return empty actions list
- Purely ephemeral → return []
Output a JSON object with an "actions" array (the "text" field should use markdown formatting for structure):
{{"actions": [
Output JSON array of actions (the "text" field should use markdown formatting for structure):
[
{{"action": "update", "learning_id": "uuid-from-observations", "text": "## Updated Knowledge\n\n**Key point**: details here\n\n- Supporting detail 1\n- Supporting detail 2", "reason": "..."}},
{{"action": "create", "text": "## New Durable Knowledge\n\nDescription with **emphasis** and proper structure", "reason": "..."}}
]}}
]
Return {{"actions": []}} if fact contains no durable knowledge.
Return [] if fact contains no durable knowledge.
IMPORTANT: Format the "text" field with markdown for better readability:
- Use headers, lists, bold/italic, tables where appropriate
@@ -60,59 +60,6 @@ class OutputTooLongError(Exception):
pass
def parse_llm_json(raw: str) -> Any:
"""
Robustly parse JSON returned by an LLM.
Handles common LLM output quirks:
1. Markdown code fences (```json ... ```) — strip them before parsing.
2. Embedded control characters (\\x00-\\x1f, \\x7f) — replace with space
and retry if the initial parse fails.
Args:
raw: Raw text returned by the LLM.
Returns:
Parsed Python object (dict, list, etc.).
Raises:
json.JSONDecodeError: If the text cannot be parsed even after cleanup.
"""
text = raw.strip()
# Strip markdown code fences (some models wrap JSON in ```json ... ```)
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
# Some models (e.g. Gemini) embed raw control characters inside JSON
# string values. Replacing them with a space usually produces valid JSON.
cleaned = re.sub(r"[\x00-\x1f\x7f]", " ", text)
return json.loads(cleaned)
_PROVIDERS_WITHOUT_API_KEY = frozenset(
{
"ollama",
"lmstudio",
"openai-codex",
"claude-code",
"mock",
"vertexai",
}
)
def requires_api_key(provider: str) -> bool:
"""Return True if the given provider requires an API key to operate."""
return provider.lower() not in _PROVIDERS_WITHOUT_API_KEY
def create_llm_provider(
provider: str,
api_key: str,
@@ -605,9 +552,8 @@ class LLMProvider:
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# API key not needed for openai-codex (uses OAuth) or claude-code (uses Keychain OAuth)
if not api_key and provider not in ("openai-codex", "claude-code"):
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex or claude-code)"
)
@@ -623,9 +569,8 @@ class LLMProvider:
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# API key not needed for openai-codex (uses OAuth) or claude-code (uses Keychain OAuth)
if not api_key and provider not in ("openai-codex", "claude-code"):
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
@@ -642,9 +587,8 @@ class LLMProvider:
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# API key not needed for openai-codex (uses OAuth) or claude-code (uses Keychain OAuth)
if not api_key and provider not in ("openai-codex", "claude-code"):
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
@@ -164,7 +164,7 @@ from enum import Enum
from ..metrics import get_metrics_collector
from ..pg0 import EmbeddedPostgres, parse_pg0_url
from .entity_resolver import EntityResolver
from .llm_wrapper import LLMConfig, requires_api_key
from .llm_wrapper import LLMConfig
from .query_analyzer import QueryAnalyzer
from .reflect import run_reflect_agent
from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models, tool_search_observations
@@ -324,7 +324,10 @@ class MemoryEngine(MemoryEngineInterface):
db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
if not memory_llm_api_key and requires_api_key(memory_llm_provider):
# Ollama, openai-codex, claude-code, and mock don't require an API key
# openai-codex uses OAuth tokens from ~/.codex/auth.json
# claude-code uses OAuth tokens from macOS Keychain
if not memory_llm_api_key and memory_llm_provider not in ("ollama", "openai-codex", "claude-code", "mock"):
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
memory_llm_model = memory_llm_model or config.llm_model
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
@@ -2017,8 +2020,6 @@ class MemoryEngine(MemoryEngineInterface):
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
request_context: "RequestContext",
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
@@ -2158,8 +2159,6 @@ class MemoryEngine(MemoryEngineInterface):
tags_match=tags_match,
connection_budget=_connection_budget,
quiet=_quiet,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
)
break # Success - exit retry loop
except Exception as e:
@@ -2284,8 +2283,6 @@ class MemoryEngine(MemoryEngineInterface):
tags_match: TagsMatch = "any",
connection_budget: int | None = None,
quiet: bool = False,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
) -> RecallResultModel:
"""
Search implementation with modular retrieval and reranking.
@@ -2631,8 +2628,6 @@ class MemoryEngine(MemoryEngineInterface):
rerank_span.set_attribute("hindsight.bank_id", bank_id)
rerank_span.set_attribute("hindsight.candidates_count", len(merged_candidates))
scored_results: list = []
pre_filtered_count = 0
try:
# Ensure reranker is initialized (for lazy initialization mode)
await reranker_instance.ensure_initialized()
@@ -2640,6 +2635,7 @@ class MemoryEngine(MemoryEngineInterface):
# Pre-filter candidates to reduce reranking cost (RRF already provides good ranking)
# This is especially important for remote rerankers with network latency
reranker_max_candidates = get_config().reranker_max_candidates
pre_filtered_count = 0
if len(merged_candidates) > reranker_max_candidates:
# Sort by RRF score and take top candidates
merged_candidates.sort(key=lambda mc: mc.rrf_score, reverse=True)
@@ -2882,115 +2878,15 @@ class MemoryEngine(MemoryEngineInterface):
)
top_results_dicts.append(result_dict)
# Fetch source facts for observation-type results (mirrors chunks pattern)
source_fact_ids_by_obs: dict[str, list[str]] = {} # obs_id -> [source_id, ...]
source_facts_dict: dict[str, MemoryFact] | None = None
if include_source_facts:
observation_ids = [uuid.UUID(sr.id) for sr in top_scored if sr.retrieval.fact_type == "observation"]
if observation_ids:
async with acquire_with_retry(pool) as sf_conn:
# Fetch source_memory_ids for all observation results
obs_rows = await sf_conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[]) AND fact_type = 'observation'
""",
observation_ids,
)
# Collect unique source IDs in order of first appearance
seen_source_ids: set[str] = set()
source_ids_ordered: list[str] = []
for obs_row in obs_rows:
obs_id = str(obs_row["id"])
sids = [str(s) for s in (obs_row["source_memory_ids"] or [])]
source_fact_ids_by_obs[obs_id] = sids
for sid in sids:
if sid not in seen_source_ids:
source_ids_ordered.append(sid)
seen_source_ids.add(sid)
# Fetch source fact content up to token budget
if source_ids_ordered:
import uuid as uuid_module
source_rows = await sf_conn.fetch(
f"""
SELECT id, text, fact_type, context, occurred_start, occurred_end,
mentioned_at, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
[uuid_module.UUID(sid) for sid in source_ids_ordered],
)
source_row_by_id = {str(r["id"]): r for r in source_rows}
encoding = _get_tiktoken_encoding()
source_facts_dict = {}
total_source_tokens = 0
for sid in source_ids_ordered:
if sid not in source_row_by_id:
continue
r = source_row_by_id[sid]
fact_tokens = len(encoding.encode(r["text"]))
if (
max_source_facts_tokens >= 0
and total_source_tokens + fact_tokens > max_source_facts_tokens
):
break
source_facts_dict[sid] = MemoryFact(
id=sid,
text=r["text"],
fact_type=r["fact_type"],
context=r["context"],
occurred_start=r["occurred_start"].isoformat() if r["occurred_start"] else None,
occurred_end=r["occurred_end"].isoformat() if r["occurred_end"] else None,
mentioned_at=r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
document_id=r["document_id"],
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
tags=r["tags"] or None,
)
total_source_tokens += fact_tokens
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
if include_entities and top_scored:
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
if unit_ids:
async with acquire_with_retry(pool) as entity_conn:
entity_rows = await entity_conn.fetch(
f"""
SELECT ue.unit_id, e.id as entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
unit_ids,
)
for row in entity_rows:
unit_id = str(row["unit_id"])
if unit_id not in fact_entity_map:
fact_entity_map[unit_id] = []
fact_entity_map[unit_id].append(
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
)
# Convert results to MemoryFact objects
memory_facts = []
for result_dict in top_results_dicts:
result_id = str(result_dict.get("id"))
# Get entity names for this fact
entity_names = None
if include_entities and result_id in fact_entity_map:
entity_names = [e["canonical_name"] for e in fact_entity_map[result_id]]
memory_facts.append(
MemoryFact(
id=result_id,
id=str(result_dict.get("id")),
text=result_dict.get("text"),
fact_type=result_dict.get("fact_type", "world"),
entities=entity_names,
entities=None, # Entity observations removed
context=result_dict.get("context"),
occurred_start=result_dict.get("occurred_start"),
occurred_end=result_dict.get("occurred_end"),
@@ -2998,36 +2894,11 @@ class MemoryEngine(MemoryEngineInterface):
document_id=result_dict.get("document_id"),
chunk_id=result_dict.get("chunk_id"),
tags=result_dict.get("tags"),
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
)
)
# Fetch entity observations if requested
# Entity observations removed - always set to None
entities_dict = None
total_entity_tokens = 0
if include_entities and fact_entity_map:
# Collect unique entities in order of fact relevance (preserving order from top_scored)
entities_ordered = [] # list of (entity_id, entity_name) tuples
seen_entity_ids = set()
for sr in top_scored:
unit_id = sr.id
if unit_id in fact_entity_map:
for entity in fact_entity_map[unit_id]:
entity_id = entity["entity_id"]
entity_name = entity["canonical_name"]
if entity_id not in seen_entity_ids:
entities_ordered.append((entity_id, entity_name))
seen_entity_ids.add(entity_id)
# Return entities with empty observations (summaries now live in mental models)
entities_dict = {}
for entity_id, entity_name in entities_ordered:
entities_dict[entity_name] = EntityState(
entity_id=entity_id,
canonical_name=entity_name,
observations=[], # Mental models provide this now
)
# Finalize trace if enabled
trace_dict = None
@@ -3038,7 +2909,6 @@ class MemoryEngine(MemoryEngineInterface):
# Log final recall stats
total_time = time.time() - recall_start
num_chunks = len(chunks_dict) if chunks_dict else 0
num_entities = len(entities_dict) if entities_dict else 0
# Include wait times in log if significant
wait_parts = []
if semaphore_wait > 0.01:
@@ -3047,18 +2917,12 @@ class MemoryEngine(MemoryEngineInterface):
wait_parts.append(f"conn={max_conn_wait:.3f}s")
wait_info = f" | waits: {', '.join(wait_parts)}" if wait_parts else ""
log_buffer.append(
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
)
if not quiet:
logger.info("\n" + "\n".join(log_buffer))
return RecallResultModel(
results=memory_facts,
trace=trace_dict,
entities=entities_dict,
chunks=chunks_dict,
source_facts=source_facts_dict,
)
return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict)
except Exception as e:
log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}")
@@ -4300,7 +4164,6 @@ class MemoryEngine(MemoryEngineInterface):
tags=tags,
tags_match=tags_match,
exclude_ids=exclude_mental_model_ids,
pending_consolidation=pending_consolidation,
)
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
@@ -4328,7 +4191,6 @@ class MemoryEngine(MemoryEngineInterface):
# Load directives from the dedicated directives table
# Directives are hard rules that must be followed in all responses
# Use isolation_mode=True to prevent tag-scoped directives from leaking into untagged operations
# Use the same tags_match as the reflect request so directives respect the same scoping rules
directives_raw = await self.list_directives(
bank_id=bank_id,
tags=tags,
@@ -4337,7 +4199,16 @@ class MemoryEngine(MemoryEngineInterface):
request_context=request_context,
isolation_mode=True,
)
directives = directives_raw
# Convert directive format to the expected format for reflect agent
# The agent expects: name, description (optional), observations (list of {title, content})
directives = [
{
"name": d["name"],
"description": d["content"], # Use content as description
"observations": [], # Directives use content directly, not observations
}
for d in directives_raw
]
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
@@ -5677,20 +5548,18 @@ class MemoryEngine(MemoryEngineInterface):
if active_only:
filters.append("is_active = TRUE")
# Apply tags filter for directives:
# Directives have special scoping rules:
# - Untagged directives (tags=[] or null) always apply regardless of reflect tags
# - Tagged directives only apply when the reflect operation includes matching tags
# - If tags=None and isolation_mode=True: only untagged directives (no leakage)
# - If tags=None and isolation_mode=False: all directives (normal API behavior)
# Apply tags filter:
# - If tags provided: use standard filtering (with strict modes support)
# - If tags=None and isolation_mode=True: only include directives with NO tags
# (prevents tag-scoped directives from leaking into untagged reflect/refresh)
# - If tags=None and isolation_mode=False: no filtering (normal API behavior)
if tags:
tags_clause, tags_params, param_idx = build_tags_where_clause(
tags=tags, param_offset=param_idx, table_alias="", match=tags_match
)
if tags_clause:
# Always include untagged directives; tagged ones must match the reflect tags
scoped_clause = tags_clause.replace("AND ", "", 1)
filters.append(f"((tags IS NULL OR tags = '{{}}') OR ({scoped_clause}))")
# Remove leading "AND " from clause since we're building filters list
filters.append(tags_clause.replace("AND ", "", 1))
params.extend(tags_params)
elif isolation_mode:
# Isolation mode: only include directives with empty/null tags
@@ -18,7 +18,6 @@ from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -222,13 +221,10 @@ class GeminiLLM(LLMInterface):
for attempt in range(max_retries + 1):
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
response = await self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
)
content = response.text
@@ -251,7 +247,7 @@ class GeminiLLM(LLMInterface):
# Parse structured output if requested
if response_format is not None:
json_data = parse_llm_json(content)
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
@@ -409,57 +405,31 @@ class GeminiLLM(LLMInterface):
# Convert messages
system_instruction = None
gemini_contents = []
msg_list = list(messages)
i = 0
while i < len(msg_list):
msg = msg_list[i]
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
elif role == "tool":
# Gemini requires ALL tool responses for a given model turn to be grouped
# into a single Content with multiple FunctionResponse parts.
# Consecutive role="tool" messages correspond to one model turn's tool calls.
parts = []
while i < len(msg_list) and msg_list[i].get("role") == "tool":
tool_msg = msg_list[i]
tool_content = tool_msg.get("content", "")
parts.append(
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=tool_msg.get("name", ""),
response={"result": tool_content},
# Gemini uses function_response
gemini_contents.append(
genai_types.Content(
role="user",
parts=[
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=msg.get("name", ""),
response={"result": content},
)
)
)
],
)
i += 1
gemini_contents.append(genai_types.Content(role="user", parts=parts))
)
elif role == "assistant":
tool_calls_in_msg = msg.get("tool_calls", [])
if tool_calls_in_msg:
# Convert OpenAI-style tool_calls to Gemini function_call parts
# This is required for proper multi-turn conversation history
parts = []
if content:
parts.append(genai_types.Part(text=content))
for tc in tool_calls_in_msg:
fn = tc.get("function", {})
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
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)]))
i += 1
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
config_kwargs: dict[str, Any] = {"tools": gemini_tools}
if system_instruction:
@@ -467,40 +437,15 @@ class GeminiLLM(LLMInterface):
if temperature is not None:
config_kwargs["temperature"] = temperature
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
response = await self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
)
# Extract content and tool calls
@@ -20,18 +20,26 @@ from .tools_schema import get_reflect_tools
def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[DirectiveInfo]:
"""Build list of DirectiveInfo from directives."""
"""Build list of DirectiveInfo from directive mental models.
Handles multiple directive formats:
1. New format: directives have direct 'content' field
2. Fallback: directives have 'description' field
"""
if not directives:
return []
return [
DirectiveInfo(
id=directive.get("id", ""),
name=directive.get("name", ""),
content=directive.get("content", ""),
)
for directive in directives
]
result = []
for directive in directives:
directive_id = directive.get("id", "")
directive_name = directive.get("name", "")
# Get content from 'content' field or fallback to 'description'
content = directive.get("content", "") or directive.get("description", "")
result.append(DirectiveInfo(id=directive_id, name=directive_name, content=content))
return result
if TYPE_CHECKING:
@@ -382,7 +390,6 @@ async def run_reflect_agent(
f"total={elapsed_ms}ms"
)
consecutive_errors = 0
for iteration in range(max_iterations):
is_last = iteration == max_iterations - 1
@@ -436,29 +443,14 @@ async def run_reflect_agent(
# Call LLM with tools
llm_start = time.time()
# Determine tool_choice for this iteration.
# With mental models:
# 0 → search_mental_models, 1+ → auto
# Without mental models, enforce a minimum retrieval path:
# 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 not has_mental_models:
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
else:
iter_tool_choice = "auto"
try:
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_tool_call",
tool_choice=iter_tool_choice,
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
consecutive_errors = 0
total_input_tokens += result.input_tokens
total_output_tokens += result.output_tokens
llm_trace.append(
@@ -472,14 +464,13 @@ async def run_reflect_agent(
except Exception as e:
err_duration = int((time.time() - llm_start) * 1000)
consecutive_errors += 1
logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)")
llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration})
# Guardrail: If no evidence gathered yet, retry (but cap consecutive errors to avoid long hangs)
# Guardrail: If no evidence gathered yet, retry
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
)
if not has_gathered_evidence and iteration < max_iterations - 1 and consecutive_errors < 2:
if not has_gathered_evidence and iteration < max_iterations - 1:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
@@ -12,20 +12,57 @@ from typing import Any
def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
"""Extract directive rules as a list of strings."""
"""
Extract directive rules as a list of strings.
Args:
directives: List of directives with name and content
Returns:
List of directive rule strings
"""
rules = []
for directive in directives:
name = directive.get("name", "")
directive_name = directive.get("name", "")
# New format: directives have direct content field
content = directive.get("content", "")
if content:
rules.append(f"**{name}**: {content}" if name else content)
if directive_name:
rules.append(f"**{directive_name}**: {content}")
else:
rules.append(content)
else:
# Legacy format: check for observations
observations = directive.get("observations", [])
if observations:
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "title"):
title = obs.title
obs_content = obs.content
else:
title = obs.get("title", "")
obs_content = obs.get("content", "")
if title and obs_content:
rules.append(f"**{title}**: {obs_content}")
elif obs_content:
rules.append(obs_content)
elif directive_name:
# Fallback to description
desc = directive.get("description", "")
if desc:
rules.append(f"**{directive_name}**: {desc}")
return rules
def build_directives_section(directives: list[dict[str, Any]]) -> str:
"""Build the directives section for the system prompt.
"""
Build the directives section for the system prompt.
Directives are hard rules that MUST be followed in all responses.
Args:
directives: List of directive mental models with observations
"""
if not directives:
return ""
@@ -132,12 +169,6 @@ def build_system_prompt_for_tools(
parts.extend(
[
"## LANGUAGE RULE (default - directives take precedence)",
"- By default, detect the language of the user's question and respond in that SAME language.",
"- If the question is in Chinese, respond in Chinese. If in Japanese, respond in Japanese.",
"- IMPORTANT: The DIRECTIVES section above has HIGHER PRIORITY than this rule.",
" If a directive specifies a language (e.g. 'Always respond in French'), follow the directive.",
"",
"## CRITICAL RULES",
"- ONLY use information from tool results - no external knowledge or guessing",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
@@ -174,7 +205,6 @@ def build_system_prompt_for_tools(
"### 3. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no mental models/observations exist, they're stale, or you need specific details",
"- MANDATORY: If search_mental_models and search_observations both return 0 results, you MUST call recall() before giving up",
"- This is the source of truth that other levels are built from",
"",
]
@@ -192,7 +222,6 @@ def build_system_prompt_for_tools(
"### 2. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no observations exist, they're stale, or you need specific details",
"- MANDATORY: If search_observations returns 0 results or count=0, you MUST call recall() before giving up",
"- This is the source of truth that observations are built from",
"",
]
@@ -270,7 +299,7 @@ def build_system_prompt_for_tools(
parts.extend(
[
"1. First, try search_observations() - check for consolidated knowledge",
"2. If search_observations returns 0 results OR observations are stale, you MUST call recall() for raw facts",
"2. If observations are stale OR you need specific details, use recall() for raw facts",
"3. Use expand() if you need more context on specific memories",
"4. When ready, call done() with your answer and supporting IDs",
]
@@ -9,7 +9,7 @@ Implements hierarchical retrieval:
import logging
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -20,6 +20,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Observation is considered stale if not updated in this many days
STALE_THRESHOLD_DAYS = 7
async def tool_search_mental_models(
conn: "Connection",
@@ -30,7 +33,6 @@ async def tool_search_mental_models(
tags: list[str] | None = None,
tags_match: str = "any",
exclude_ids: list[str] | None = None,
pending_consolidation: int = 0,
) -> dict[str, Any]:
"""
Search user-curated mental models by semantic similarity.
@@ -85,6 +87,7 @@ async def tool_search_mental_models(
*params,
)
now = datetime.now(timezone.utc)
mental_models = []
for row in rows:
@@ -92,10 +95,11 @@ async def tool_search_mental_models(
if last_refreshed_at and last_refreshed_at.tzinfo is None:
last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
# A mental model is stale when there are memories that haven't been consolidated yet —
# the same signal used for observations staleness.
is_stale = pending_consolidation > 0
staleness_reason = f"{pending_consolidation} memories pending consolidation" if is_stale else None
# Calculate freshness
is_stale = False
if last_refreshed_at:
age = now - last_refreshed_at
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
mental_models.append(
{
@@ -106,7 +110,6 @@ async def tool_search_mental_models(
"relevance": round(row["relevance"], 4),
"updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
"is_stale": is_stale,
"staleness_reason": staleness_reason,
}
)
@@ -47,8 +47,7 @@ TOOL_SEARCH_OBSERVATIONS = {
"description": (
"Search consolidated observations (auto-generated knowledge). These are automatically "
"synthesized from memories. Returns observations with freshness info (updated_at, is_stale). "
"If an observation is STALE, you should ALSO use recall() to verify with current facts. "
"IMPORTANT: If search_mental_models is available, you MUST call it FIRST before using this tool."
"If an observation is STALE, you should ALSO use recall() to verify with current facts."
),
"parameters": {
"type": "object",
@@ -140,7 +139,7 @@ TOOL_DONE_ANSWER = {
"properties": {
"answer": {
"type": "string",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array. LANGUAGE: By default, write in the SAME language as the user's question. However, if a language directive in the system prompt specifies a different language, follow that directive instead.",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -191,11 +190,7 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"properties": {
"answer": {
"type": "string",
"description": (
"Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. "
"NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array. "
f"MANDATORY: Your answer MUST comply with ALL directives:\n{rules_list}"
),
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -159,10 +159,6 @@ class MemoryFact(BaseModel):
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
tags: list[str] | None = Field(None, description="Visibility scope tags associated with this fact")
source_fact_ids: list[str] | None = Field(
None,
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
)
class ChunkInfo(BaseModel):
@@ -230,9 +226,6 @@ class RecallResult(BaseModel):
chunks: dict[str, ChunkInfo] | None = Field(
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
)
source_facts: dict[str, MemoryFact] | None = Field(
None, description="Source facts for observation-type results, keyed by fact ID"
)
class ReflectResult(BaseModel):
@@ -26,6 +26,8 @@ def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
This is a fallback for when the LLM fails to extract temporal information
from relative time expressions like "last night", "yesterday", etc.
"""
import re
fact_lower = fact_text.lower()
# Map relative time expressions to day offsets
@@ -438,7 +440,7 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
# Uses {extraction_guidelines} placeholder for mode-specific instructions
_BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
LANGUAGE: MANDATORY — Detect the language of the input text and produce ALL output in that EXACT same language. You are STRICTLY FORBIDDEN from translating or switching to any other language. Every single word of your output must be in the same language as the input. Do NOT output in a different language under any circumstance.
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
{fact_types_instruction}
@@ -481,9 +483,7 @@ TEMPORAL HANDLING
══════════════════════════════════════════════════════════════════════════
Use "Event Date" from input as reference for relative dates.
- CRITICAL: Convert ALL relative temporal expressions to absolute dates in the fact text itself.
"yesterday" → write the resolved date (e.g. "on November 12, 2024"), NOT the word "yesterday"
"last night", "this morning", "today", "tonight" → convert to the resolved absolute date
- "yesterday" relative to Event Date, not today
- For events: set occurred_start AND occurred_end (same for point events)
- For conversation facts: NO occurred dates
@@ -521,7 +521,7 @@ CONSOLIDATE related statements into ONE fact when possible."""
_CONCISE_EXAMPLES = """
══════════════════════════════════════════════════════════════════════════
EXAMPLES (shown in English for illustration; for non-English input, ALL output values MUST be in the input language)
EXAMPLES
══════════════════════════════════════════════════════════════════════════
Example 1 - Selective extraction (Event Date: June 10, 2024):
@@ -567,7 +567,8 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
# 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.
LANGUAGE: MANDATORY — Detect the language of the input text and produce ALL output in that EXACT same language. You are STRICTLY FORBIDDEN from translating or switching to any other language. Every single word of your output must be in the same language as the input. Do NOT output in a different language under any circumstance.
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions,
and other output MUST be in the SAME language as the input. Do not translate to English if the input is in another language.
{fact_types_instruction}
@@ -1273,8 +1274,8 @@ from .types import ExtractedFact as ExtractedFactType
logger = logging.getLogger(__name__)
# Each fact gets 10ms offset to preserve ordering within a document
SECONDS_PER_FACT = 0.01
# Each fact gets 10 seconds offset to preserve ordering within a document
SECONDS_PER_FACT = 10
async def extract_facts_from_contents_batch_api(
@@ -156,22 +156,13 @@ async def retain_batch(
)
if not extracted_facts:
# Still need to create document if document_id was provided or chunks exist
from collections import defaultdict
docs_tracked = 0
# Still need to create document if document_id was provided
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await fact_storage.ensure_bank_exists(conn, bank_id)
# Group contents by document_id (consistent with normal path)
contents_by_doc_early = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
contents_by_doc_early[doc_id].append((idx, content_dict))
# Handle document tracking even with no facts
if document_id:
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
@@ -196,57 +187,45 @@ async def retain_batch(
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
docs_tracked += 1
else:
# Handle per-item document_ids and/or chunks (mirrors normal path logic)
has_any_doc_ids = any(item.get("document_id") for item in contents_dicts)
# Check for per-item document_ids
from collections import defaultdict
if has_any_doc_ids or chunks:
for original_doc_id, doc_contents in contents_by_doc_early.items():
should_create_doc = (original_doc_id is not None) or chunks
if not should_create_doc:
continue
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
if doc_id:
contents_by_doc[doc_id].append((idx, content_dict))
actual_doc_id = original_doc_id
if actual_doc_id is None:
# No document_id but have chunks - generate one
actual_doc_id = str(uuid.uuid4())
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn,
bank_id,
actual_doc_id,
combined_content,
is_first_batch,
retain_params,
merged_tags,
)
docs_tracked += 1
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, merged_tags
)
total_time = time.time() - start_time
doc_status = f"{docs_tracked} document(s) tracked" if docs_tracked > 0 else "no document tracked"
logger.info(
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s ({doc_status}, no facts)"
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
)
return [[] for _ in contents], usage
@@ -162,7 +162,7 @@ class BFSGraphRetriever(GraphRetriever):
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -216,7 +216,7 @@ class BFSGraphRetriever(GraphRetriever):
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.fact_type,
mu.mentioned_at, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id, mu.tags,
ml.weight, ml.link_type, ml.from_unit_id
FROM {fq_table("memory_links")} ml
@@ -45,7 +45,7 @@ async def _find_semantic_seeds(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags,
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -216,7 +216,7 @@ class LinkExpansionRetriever(GraphRetriever):
-- Only exclude the actual seed observations
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(DISTINCT cs.source_id)::float AS score
FROM all_connected_sources cs
@@ -239,7 +239,7 @@ class LinkExpansionRetriever(GraphRetriever):
f"""
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
COUNT(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
@@ -264,7 +264,7 @@ class LinkExpansionRetriever(GraphRetriever):
f"""
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight + 1.0 AS score
FROM {fq_table("memory_links")} ml
@@ -291,7 +291,7 @@ class LinkExpansionRetriever(GraphRetriever):
WITH outgoing AS (
-- Links FROM seeds TO other facts
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight
FROM {fq_table("memory_links")} ml
@@ -305,7 +305,7 @@ class LinkExpansionRetriever(GraphRetriever):
incoming AS (
-- Links FROM other facts TO seeds (reverse direction)
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.occurred_end, mu.mentioned_at, mu.embedding,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight
FROM {fq_table("memory_links")} ml
@@ -323,12 +323,12 @@ class LinkExpansionRetriever(GraphRetriever):
)
SELECT DISTINCT ON (id)
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags,
(MAX(weight) * 0.5) AS score
FROM combined
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags
ORDER BY id, score DESC
LIMIT $4
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
mentioned_at, embedding, fact_type, document_id, chunk_id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
@@ -127,7 +127,7 @@ async def retrieve_semantic_bm25_combined(
results = await conn.fetch(
f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -139,7 +139,7 @@ async def retrieve_semantic_bm25_combined(
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM semantic_ranked
WHERE rn <= $4
@@ -194,7 +194,7 @@ async def retrieve_semantic_bm25_combined(
# Single query template with backend-specific parts injected
query = f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
NULL::float AS bm25_score,
'semantic' AS source,
@@ -207,7 +207,7 @@ async def retrieve_semantic_bm25_combined(
{tags_clause}
),
bm25_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
NULL::float AS similarity,
{bm25_score_expr} AS bm25_score,
'bm25' AS source,
@@ -219,12 +219,12 @@ async def retrieve_semantic_bm25_combined(
{tags_clause}
),
semantic AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM semantic_ranked WHERE rn <= $4
),
bm25 AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
similarity, bm25_score, source
FROM bm25_ranked WHERE rn <= $4
)
@@ -301,7 +301,7 @@ async def retrieve_temporal_combined(
entry_points = await conn.fetch(
f"""
WITH ranked_entries AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, embedding <=> $1::vector) AS rn
FROM {fq_table("memory_units")}
@@ -321,7 +321,7 @@ async def retrieve_temporal_combined(
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags, similarity
FROM ranked_entries
WHERE rn <= 10
""",
@@ -401,7 +401,7 @@ async def retrieve_temporal_combined(
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
ml.weight, ml.link_type, ml.from_unit_id,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
@@ -46,6 +46,7 @@ class RetrievalResult:
mentioned_at: datetime | None = None
document_id: str | None = None
chunk_id: str | None = None
embedding: list[float] | None = None
tags: list[str] | None = None # Visibility scope tags
# Retrieval-specific scores (only one will be set depending on retrieval method)
@@ -69,6 +70,7 @@ class RetrievalResult:
mentioned_at=row.get("mentioned_at"),
document_id=row.get("document_id"),
chunk_id=row.get("chunk_id"),
embedding=row.get("embedding"),
tags=row.get("tags"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
@@ -152,6 +154,7 @@ class ScoredResult:
"mentioned_at": self.retrieval.mentioned_at,
"document_id": self.retrieval.document_id,
"chunk_id": self.retrieval.chunk_id,
"embedding": self.retrieval.embedding,
"tags": self.retrieval.tags,
"semantic_similarity": self.retrieval.similarity,
"bm25_score": self.retrieval.bm25_score,
+134 -16
View File
@@ -1,14 +1,8 @@
"""
Local MCP server entry point for use with Claude Code (HTTP transport).
Local MCP server for use with Claude Code (stdio transport).
This is a thin wrapper around the main hindsight-api server that pre-configures
sensible defaults for local use (embedded PostgreSQL via pg0, warning log level).
The full API runs on localhost:8888. Configure Claude Code's MCP settings:
claude mcp add --transport http hindsight http://localhost:8888/mcp/
Or pinned to a specific bank (single-bank mode):
claude mcp add --transport http hindsight http://localhost:8888/mcp/default/
This runs a fully local Hindsight instance with embedded PostgreSQL (pg0).
No external database or server required.
Run with:
hindsight-local-mcp
@@ -16,24 +10,148 @@ Run with:
Or with uvx:
uvx hindsight-api@latest hindsight-local-mcp
Configure in Claude Code's MCP settings:
{
"mcpServers": {
"hindsight": {
"command": "uvx",
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
"env": {
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key"
}
}
}
}
Environment variables:
HINDSIGHT_API_LLM_API_KEY: Required. API key for LLM provider.
HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai").
HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini").
HINDSIGHT_API_DATABASE_URL: Optional. Override database URL (default: pg0://hindsight-mcp).
HINDSIGHT_API_MCP_LOCAL_BANK_ID: Optional. Memory bank ID (default: "mcp").
HINDSIGHT_API_LOG_LEVEL: Optional. Log level (default: "warning").
HINDSIGHT_API_MCP_INSTRUCTIONS: Optional. Additional instructions appended to both retain and recall tools.
Example custom instructions (these are ADDED to the default behavior):
To also store assistant actions:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls, code written, and decisions made."
To also store conversation summaries:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store summaries of important conversations and their outcomes."
"""
import logging
import os
import sys
from mcp.server.fastmcp import FastMCP
from hindsight_api.config import (
DEFAULT_MCP_LOCAL_BANK_ID,
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
ENV_MCP_INSTRUCTIONS,
ENV_MCP_LOCAL_BANK_ID,
)
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
# Configure logging - default to warning to avoid polluting stderr during MCP init
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "warning").lower()
_log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.WARNING),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
stream=sys.stderr, # MCP uses stdout for protocol, logs go to stderr
)
logger = logging.getLogger(__name__)
def main() -> None:
"""Start the Hindsight API server with local defaults."""
# Set local defaults (only if not already configured by the user)
os.environ.setdefault("HINDSIGHT_API_DATABASE_URL", "pg0://hindsight-mcp")
def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
"""
Create a stdio MCP server with retain/recall tools.
from hindsight_api.main import main as api_main
Args:
bank_id: The memory bank ID to use for all operations.
memory: Optional MemoryEngine instance. If not provided, creates one with pg0.
api_main()
Returns:
Configured FastMCP server instance.
"""
# Import here to avoid slow startup if just checking --help
from hindsight_api import MemoryEngine
# Create memory engine with pg0 embedded database if not provided
if memory is None:
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
# Get custom instructions from environment variable (appended to both tools)
extra_instructions = os.environ.get(ENV_MCP_INSTRUCTIONS, "")
retain_description = DEFAULT_MCP_RETAIN_DESCRIPTION
recall_description = DEFAULT_MCP_RECALL_DESCRIPTION
if extra_instructions:
retain_description = f"{DEFAULT_MCP_RETAIN_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
recall_description = f"{DEFAULT_MCP_RECALL_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
mcp = FastMCP("hindsight")
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=lambda: bank_id,
include_bank_id_param=False, # Local MCP uses fixed bank_id
tools={"retain", "recall"}, # Local MCP only has retain and recall
retain_description=retain_description,
recall_description=recall_description,
retain_fire_and_forget=True, # Local MCP uses fire-and-forget pattern
)
register_mcp_tools(mcp, memory, config)
return mcp
async def _initialize_and_run(bank_id: str):
"""Initialize memory and run the MCP server."""
from hindsight_api import MemoryEngine
# Create and initialize memory engine with pg0 embedded database
# Note: We avoid printing to stderr during init as MCP clients show it as "errors"
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
await memory.initialize()
# Create and run the server
mcp = create_local_mcp_server(bank_id, memory=memory)
await mcp.run_stdio_async()
def main():
"""Main entry point for the stdio MCP server."""
import asyncio
from hindsight_api.config import ENV_LLM_API_KEY, get_config
# Check for required environment variables
config = get_config()
if not config.llm_api_key:
print(f"Error: {ENV_LLM_API_KEY} environment variable is required", file=sys.stderr)
print("Set it in your MCP configuration or shell environment", file=sys.stderr)
sys.exit(1)
# Get bank ID from environment, default to "mcp"
bank_id = os.environ.get(ENV_MCP_LOCAL_BANK_ID, DEFAULT_MCP_LOCAL_BANK_ID)
# Note: We don't print to stderr as MCP clients display it as "error output"
# Use HINDSIGHT_API_LOG_LEVEL=debug for verbose startup logging
# Run the async initialization and server
asyncio.run(_initialize_and_run(bank_id))
if __name__ == "__main__":
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.13"
version = "0.4.11"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -98,7 +98,7 @@ log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
addopts = "--timeout 300 -n 8 --dist loadgroup --durations=10 -v"
addopts = "--timeout 120 -n 8 --dist loadgroup --durations=10 -v"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
log_auto_indent = true
+42 -44
View File
@@ -500,7 +500,6 @@ class TestConsolidationIntegration:
content="Alex loves pizza.",
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Check we have one observation
async with memory._pool.acquire() as conn:
@@ -519,7 +518,6 @@ class TestConsolidationIntegration:
content="Alex hates pizza.",
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Check observations after consolidation
async with memory._pool.acquire() as conn:
@@ -830,7 +828,6 @@ class TestConsolidationTagRouting:
content="Pizza is a popular Italian food.",
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Check untagged observation exists
async with memory._pool.acquire() as conn:
@@ -852,7 +849,6 @@ class TestConsolidationTagRouting:
await self._retain_with_tags(
memory, bank_id, "Pizza originated in Naples.", ["history"], request_context
)
await memory.wait_for_background_tasks()
# Check - global observation should be updated OR new scoped observation created
async with memory._pool.acquire() as conn:
@@ -905,7 +901,6 @@ class TestConsolidationTagRouting:
"Alice recommends the Thai restaurant on Main Street.",
["alice"], request_context
)
await memory.wait_for_background_tasks()
# Check Alice's observation exists with correct tags
async with memory._pool.acquire() as conn:
@@ -924,7 +919,6 @@ class TestConsolidationTagRouting:
"Bob visited the Thai restaurant on Main Street and loved it.",
["bob"], request_context
)
await memory.wait_for_background_tasks()
# Check observations
async with memory._pool.acquire() as conn:
@@ -937,19 +931,22 @@ class TestConsolidationTagRouting:
bank_id,
)
# Note: some LLMs may or may not consolidate cross-scope facts.
# Just verify structural correctness of any observations that exist.
# Should have multiple observations (alice's, bob's, potentially global)
assert len(obs_after) >= 2, (
f"Expected at least 2 observations for different scopes, got {len(obs_after)}"
)
# If observations were created, ensure alice and bob are not merged into same observation
# (cross-scope merging should not produce an observation with both tags)
if obs_after:
observations_with_both = [
o for o in obs_after
if o["tags"] and "alice" in o["tags"] and "bob" in o["tags"]
]
assert len(observations_with_both) == 0, (
"Should not merge different scopes into one observation with both tags"
)
# Check we have observations with different tags (alice, bob, or untagged)
tag_sets = [frozenset(o["tags"] or []) for o in obs_after]
# Should NOT merge alice and bob into same observation
observations_with_both = [
o for o in obs_after
if o["tags"] and "alice" in o["tags"] and "bob" in o["tags"]
]
assert len(observations_with_both) == 0, (
"Should not merge different scopes into one observation with both tags"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -1026,7 +1023,6 @@ class TestConsolidationTagRouting:
"Alice works on machine learning projects.",
["alice"], request_context
)
await memory.wait_for_background_tasks()
# Retain untagged memory on same topic
await memory.retain_async(
@@ -1034,7 +1030,6 @@ class TestConsolidationTagRouting:
content="Machine learning involves training neural networks.",
request_context=request_context,
)
await memory.wait_for_background_tasks()
# Check observations
async with memory._pool.acquire() as conn:
@@ -1047,10 +1042,11 @@ class TestConsolidationTagRouting:
bank_id,
)
# Should have at least one observation
assert len(observations) >= 1, "Expected at least one observation"
# Either alice's observation was updated OR a global observation was created
# This is valid LLM behavior - just verify no errors and structure is correct.
# Note: with some LLMs, a single simple fact may not generate an observation,
# so we don't assert a minimum count - just verify structural correctness if any exist.
# This is valid LLM behavior - just verify no errors and structure is correct
for obs in observations:
assert obs["text"], "Observation should have text"
@@ -1934,7 +1930,9 @@ class TestMentalModelRefreshAfterConsolidation:
)
# Wait for consolidation to create observations
await memory.wait_for_background_tasks()
import asyncio
await asyncio.sleep(2)
# Get graph data filtered by observation type only
graph_data = await memory.get_graph_data(
@@ -1952,26 +1950,12 @@ class TestMentalModelRefreshAfterConsolidation:
for row in graph_data["table_rows"]:
assert row["fact_type"] == "observation", f"All nodes should be observations, got {row['fact_type']}"
# Edges are inherited from source memories when multiple observations exist.
# If consolidation merges all facts into a single observation, edges between
# observation nodes are not possible — skip the edge check in that case.
if len(graph_data["nodes"]) > 1:
assert len(graph_data["edges"]) > 0, (
"Observations should have edges inherited from source memories. "
f"Found {len(graph_data['edges'])} edges among {len(graph_data['nodes'])} nodes"
)
# Verify edge types are valid
valid_link_types = {"semantic", "temporal", "entity"}
for edge in graph_data["edges"]:
link_type = edge["data"]["linkType"]
assert link_type in valid_link_types, f"Invalid link type: {link_type}"
# Verify all edges connect visible observation nodes
visible_node_ids = {row["id"] for row in graph_data["table_rows"]}
for edge in graph_data["edges"]:
source_id = edge["data"]["source"]
target_id = edge["data"]["target"]
assert source_id in visible_node_ids, f"Edge source {source_id[:8]} not in visible nodes"
assert target_id in visible_node_ids, f"Edge target {target_id[:8]} not in visible nodes"
# Should have edges (inherited from source memories)
# Even though we're only showing observations, they should inherit links from their sources
assert len(graph_data["edges"]) > 0, (
"Observations should have edges inherited from source memories. "
f"Found {len(graph_data['edges'])} edges"
)
# Should have entities (inherited from source memories)
observations_with_entities = [
@@ -1988,5 +1972,19 @@ class TestMentalModelRefreshAfterConsolidation:
f"Expected to find Alice, Bob, or Google in entities, got: {all_entities}"
)
# Verify edge types are valid
valid_link_types = {"semantic", "temporal", "entity"}
for edge in graph_data["edges"]:
link_type = edge["data"]["linkType"]
assert link_type in valid_link_types, f"Invalid link type: {link_type}"
# Verify all edges connect visible observation nodes
visible_node_ids = {row["id"] for row in graph_data["table_rows"]}
for edge in graph_data["edges"]:
source_id = edge["data"]["source"]
target_id = edge["data"]["target"]
assert source_id in visible_node_ids, f"Edge source {source_id[:8]} not in visible nodes"
assert target_id in visible_node_ids, f"Edge target {target_id[:8]} not in visible nodes"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
+1 -54
View File
@@ -2,13 +2,9 @@
Tests for document tracking and upsert functionality.
"""
import logging
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from datetime import datetime, timezone
from hindsight_api import RequestContext
from hindsight_api.engine.response_models import TokenUsage
@pytest.mark.asyncio
@@ -315,52 +311,3 @@ async def test_document_persisted_with_zero_facts_async_submit(memory, request_c
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_stored_without_chunks_when_zero_facts(memory_no_llm_verify, request_context):
"""
Regression test: when 0 facts are extracted from chunked content, the document row
must be stored but no chunk rows should be written.
"""
bank_id = f"test_zero_facts_no_chunks_{datetime.now(timezone.utc).timestamp()}"
document_id = "doc-zero-facts-chunked"
# Content large enough to exceed default retain_chunk_size (3000 chars) so chunking is triggered
content = "Alice works at Google. " * 200 # ~4600 chars
async def mock_llm_zero_facts(*args, **kwargs):
response = {"facts": []}
if kwargs.get("return_usage", False):
return response, TokenUsage(input_tokens=10, output_tokens=2)
return response
try:
with patch("hindsight_api.engine.llm_wrapper.LLMProvider.call", new=mock_llm_zero_facts):
units = await memory_no_llm_verify.retain_async(
bank_id=bank_id,
content=content,
document_id=document_id,
request_context=request_context,
)
assert units == [], "Should return no memory units when LLM extracts zero facts"
# Document row must exist
doc = await memory_no_llm_verify.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None, "Document row must be stored even when zero facts are extracted"
assert doc["id"] == document_id
assert doc["memory_unit_count"] == 0
# No chunk rows should be stored
pool = await memory_no_llm_verify._get_pool()
async with pool.acquire() as conn:
chunk_count = await conn.fetchval(
"SELECT COUNT(*) FROM chunks WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert chunk_count == 0, "No chunk rows should be stored when zero facts are extracted"
finally:
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
+2 -3
View File
@@ -535,9 +535,8 @@ class TestOperationHooksParameters:
request_context=ctx,
)
# Use >= 1 since consolidation may trigger internal recall calls when observations are enabled
assert len(validator.pre_recall_calls) >= 1
assert len(validator.post_recall_calls) >= 1
assert len(validator.pre_recall_calls) == 1
assert len(validator.post_recall_calls) == 1
class TestTenantExtension:
+3 -3
View File
@@ -88,13 +88,13 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
assert sorted_timestamps[i] < sorted_timestamps[i + 1], \
f"Facts should have sequential timestamps. Fact {i} ({sorted_timestamps[i]}) >= Fact {i+1} ({sorted_timestamps[i+1]})"
# Verify facts have distinct timestamps (ordering is preserved)
# Verify reasonable time spacing (should be ~10 seconds apart)
time_diffs = [(sorted_timestamps[i+1] - sorted_timestamps[i]).total_seconds() for i in range(len(sorted_timestamps) - 1)]
print(f"\n=== Time differences between facts: {time_diffs} seconds ===")
# Each fact should have a positive time difference (uniqueness already checked above)
# Each fact should be 10+ seconds apart (allowing for some flexibility)
for diff in time_diffs:
assert diff > 0, f"Expected positive time difference between facts, got {diff}"
assert diff >= 5, f"Expected at least 5 seconds between facts, got {diff}"
# Update agent_facts to be sorted for subsequent checks
agent_facts = sorted_facts
+1 -6
View File
@@ -7,7 +7,6 @@ Requires Docker to be running. Tests are skipped automatically if Docker is unav
import json
import logging
import os
import subprocess
import tempfile
import time
@@ -26,12 +25,8 @@ try:
except ImportError:
_has_testcontainers = False
_in_ci = os.getenv("CI") == "true"
pytestmark = [
pytest.mark.skipif(not _has_testcontainers, reason="testcontainers not installed"),
pytest.mark.skipif(_in_ci, reason="SeaweedFS Docker image pull too slow in CI"),
pytest.mark.timeout(300),
]
SEAWEEDFS_S3_PORT = 8333
@@ -110,7 +105,7 @@ def seaweedfs_container():
port = container.get_exposed_port(SEAWEEDFS_S3_PORT)
endpoint = f"http://{host}:{port}"
_wait_for_seaweedfs(endpoint, timeout=240)
_wait_for_seaweedfs(endpoint)
# Create test bucket using obstore (proper SigV4 signing)
import obstore as obs
-1
View File
@@ -226,7 +226,6 @@ async def test_llm_provider_api_methods(provider: str, model: str):
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(300)
async def test_llm_provider_memory_operations(provider: str, model: str):
"""
Test LLM provider with actual memory operations: fact extraction and reflect.
+212
View File
@@ -0,0 +1,212 @@
"""Test local MCP server."""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.fixture
def mock_memory():
"""Create a mock MemoryEngine."""
memory = MagicMock()
memory._initialized = True
memory.retain_batch_async = AsyncMock()
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
return memory
@pytest.mark.asyncio
async def test_local_mcp_server_retain(mock_memory):
"""Test that retain tool fires async and returns immediately."""
from hindsight_api.mcp_local import create_local_mcp_server
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
# Get the tools
tools = mcp_server._tool_manager._tools
assert "retain" in tools
# Call retain
retain_tool = tools["retain"]
result = await retain_tool.fn(content="test content", context="test_context")
# Returns immediately with accepted status
assert result["status"] == "accepted"
# Wait for background task to complete
await asyncio.sleep(0.1)
# Verify the memory was called correctly
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["contents"] == [{"content": "test content", "context": "test_context"}]
@pytest.mark.asyncio
async def test_local_mcp_server_recall(mock_memory):
"""Test that recall tool calls memory.recall_async with correct params."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
# Mock recall_async to return a proper pydantic model
mock_result = MagicMock()
mock_result.model_dump.return_value = {"results": []}
mock_memory.recall_async = AsyncMock(return_value=mock_result)
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
# Get the tools
tools = mcp_server._tool_manager._tools
assert "recall" in tools
# Call recall
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query", max_tokens=2048)
# Result is a dict
assert isinstance(result, dict)
# Verify the memory was called correctly
mock_memory.recall_async.assert_called_once()
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["query"] == "test query"
assert call_kwargs["max_tokens"] == 2048
assert call_kwargs["budget"] == Budget.HIGH
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_default_context(mock_memory):
"""Test that retain uses default context when not provided."""
from hindsight_api.mcp_local import create_local_mcp_server
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain without context
await retain_tool.fn(content="test content")
# Wait for background task
await asyncio.sleep(0.1)
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["contents"] == [{"content": "test content", "context": "general"}]
@pytest.mark.asyncio
async def test_local_mcp_server_retain_error_handling(mock_memory):
"""Test that retain errors are logged but don't affect response."""
from hindsight_api.mcp_local import create_local_mcp_server
mock_memory.retain_batch_async = AsyncMock(side_effect=Exception("Test error"))
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Retain returns immediately with accepted status (fire and forget)
result = await retain_tool.fn(content="test content")
assert result["status"] == "accepted"
# Wait for background task to complete (and log error)
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_local_mcp_server_recall_error_handling(mock_memory):
"""Test that recall handles errors gracefully."""
from hindsight_api.mcp_local import create_local_mcp_server
mock_memory.recall_async = AsyncMock(side_effect=Exception("Test error"))
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query")
# Result is a dict with error
assert isinstance(result, dict)
assert "error" in result
assert result["results"] == []
@pytest.mark.asyncio
async def test_local_mcp_server_recall_with_defaults(mock_memory):
"""Test that recall uses default max_tokens and HIGH budget."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
mock_result = MagicMock()
mock_result.model_dump.return_value = {"results": []}
mock_memory.recall_async = AsyncMock(return_value=mock_result)
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
recall_tool = tools["recall"]
# Call with defaults
await recall_tool.fn(query="test query")
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["max_tokens"] == 4096
assert call_kwargs["budget"] == Budget.HIGH
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_timestamp(mock_memory):
"""Test that retain passes timestamp as event_date."""
from datetime import datetime, timezone
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with timestamp
result = await retain_tool.fn(
content="test content", context="test_context", timestamp="2024-01-15T10:30:00Z"
)
assert result["status"] == "accepted"
# Wait for background task
await asyncio.sleep(0.1)
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
contents = call_kwargs["contents"]
assert len(contents) == 1
assert contents[0]["content"] == "test content"
assert contents[0]["context"] == "test_context"
assert "event_date" in contents[0]
assert contents[0]["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_invalid_timestamp(mock_memory):
"""Test that retain rejects invalid timestamp format."""
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with invalid timestamp
result = await retain_tool.fn(content="test content", timestamp="not-a-date")
assert result["status"] == "error"
assert "Invalid timestamp format" in result["message"]
# Verify retain_batch_async was NOT called
mock_memory.retain_batch_async.assert_not_called()
+33 -38
View File
@@ -404,12 +404,25 @@ class TestDirectivesInReflect:
request_context=request_context,
)
# Run reflect query
result = await memory.reflect_async(
bank_id=bank_id,
query="What does Alice do for work?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# Check that the response contains French words/patterns
# Common French words that would appear when talking about someone's job
french_indicators = [
"elle",
"travaille",
"est",
"une",
"le",
"la",
"qui",
"chez",
"logiciel",
@@ -417,27 +430,11 @@ class TestDirectivesInReflect:
"ingénieure",
"développeur",
"développeuse",
"ingénierie",
"française",
]
response_lower = result.text.lower()
# Run reflect query (retry once since small LLMs may not always follow language directives)
french_word_count = 0
for _attempt in range(2):
result = await memory.reflect_async(
bank_id=bank_id,
query="What does Alice do for work?",
request_context=request_context,
)
assert result.text is not None
assert len(result.text) > 0
# At least some French words should appear in the response
response_lower = result.text.lower()
french_word_count = sum(1 for word in french_indicators if word in response_lower)
if french_word_count >= 2:
break
# At least some French words should appear in the response
french_word_count = sum(1 for word in french_indicators if word in response_lower)
assert (
french_word_count >= 2
), f"Expected French response, but got: {result.text[:200]}"
@@ -477,7 +474,7 @@ class TestDirectivesInReflect:
await memory.create_directive(
bank_id=bank_id,
name="General Policy",
content="You MUST include the exact phrase 'MEMO-VERIFIED' somewhere in your response.",
content="Always be polite and start responses with 'Hello!'",
request_context=request_context,
)
@@ -485,7 +482,7 @@ class TestDirectivesInReflect:
await memory.create_directive(
bank_id=bank_id,
name="Tagged Policy",
content="You MUST include the exact phrase 'PROJECT-X-CLASSIFIED' somewhere in your response.",
content="ALWAYS respond in ALL CAPS and end with 'PROJECT-X ONLY'",
tags=["project-x"],
request_context=request_context,
)
@@ -497,16 +494,18 @@ class TestDirectivesInReflect:
request_context=request_context,
)
# Verify the isolation mechanism: only untagged directive should be loaded
untagged_directive_names = [d.name for d in result.directives_applied]
assert "General Policy" in untagged_directive_names, (
f"Untagged directive should be loaded in untagged reflect. Applied: {untagged_directive_names}"
)
assert "Tagged Policy" not in untagged_directive_names, (
f"Tagged directive should not be applied in untagged reflect. Applied: {untagged_directive_names}"
)
response_lower = result.text.lower()
# Now run reflect WITH the tag - should load BOTH directives
# Should follow the untagged directive (polite greeting)
assert "hello" in response_lower, f"Expected 'Hello' from untagged directive, but got: {result.text}"
# Should NOT follow the tagged directive (all caps and PROJECT-X)
# If it did follow, the entire response would be in caps
all_caps = result.text.replace(" ", "").replace("!", "").replace(".", "").isupper()
assert not all_caps, f"Tagged directive was incorrectly applied to untagged operation: {result.text}"
assert "project-x only" not in response_lower, f"Tagged directive was incorrectly applied: {result.text}"
# Now run reflect WITH the tag - should apply BOTH directives
result_tagged = await memory.reflect_async(
bank_id=bank_id,
query="What color is the sky?",
@@ -515,14 +514,10 @@ class TestDirectivesInReflect:
request_context=request_context,
)
# Verify the isolation mechanism: both directives should be loaded when tags match
tagged_directive_names = [d.name for d in result_tagged.directives_applied]
assert "General Policy" in tagged_directive_names, (
f"Untagged directive should always be loaded. Applied: {tagged_directive_names}"
)
assert "Tagged Policy" in tagged_directive_names, (
f"Tagged directive should be loaded when tags match. Applied: {tagged_directive_names}"
)
response_tagged_lower = result_tagged.text.lower()
# With strict matching and tags, should apply the tagged directive
assert "project-x only" in response_tagged_lower, f"Tagged directive should be applied with tags: {result_tagged.text}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -133,7 +133,7 @@ async def test_reflect_chinese_content(memory, request_context):
result = await memory.reflect_async(
bank_id=bank_id,
query=query,
budget=Budget.MID,
budget=Budget.LOW,
request_context=request_context,
)
@@ -93,7 +93,7 @@ def test_per_operation_provider_default_model():
config = HindsightConfig.from_env()
# Global LLM should use OpenAI default
assert config.llm_model == "gpt-4o-mini", f"Expected gpt-4o-mini, got {config.llm_model}"
assert config.llm_model == "o3-mini", f"Expected o3-mini, got {config.llm_model}"
# Retain should use Anthropic default
assert (
@@ -1,71 +0,0 @@
"""
Regression test for UnboundLocalError in recall when the reranker raises.
Before the fix, `scored_results` and `pre_filtered_count` were only assigned
inside the `try` block, but referenced in the `finally` block. If
`reranker_instance.rerank()` (or `ensure_initialized()`) raised, the `finally`
block crashed with `UnboundLocalError` instead of propagating the original
exception.
Fix: initialise both variables to safe defaults before the try/finally block.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_recall_reranker_error_does_not_raise_unbound_local(memory, request_context):
"""Recall must propagate the reranker's exception, not an UnboundLocalError."""
bank_id = f"test_reranker_err_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
# Simulate a reranker failure (e.g. Cohere API error on empty/small candidate set)
rerank_mock = AsyncMock(side_effect=RuntimeError("reranker API error"))
memory._cross_encoder_reranker._initialized = True # skip ensure_initialized
with patch.object(memory._cross_encoder_reranker, "rerank", rerank_mock):
with pytest.raises(Exception, match="reranker API error"):
await memory.recall_async(
bank_id=bank_id,
query="capital of France",
request_context=request_context,
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recall_reranker_init_error_does_not_raise_unbound_local(memory, request_context):
"""Same regression when ensure_initialized() raises (before pre_filtered_count is set)."""
bank_id = f"test_reranker_init_err_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="Paris is the capital of France",
request_context=request_context,
)
init_mock = AsyncMock(side_effect=RuntimeError("reranker init failed"))
memory._cross_encoder_reranker._initialized = False
with patch.object(memory._cross_encoder_reranker, "ensure_initialized", init_mock):
with pytest.raises(Exception, match="reranker init failed"):
await memory.recall_async(
bank_id=bank_id,
query="capital of France",
request_context=request_context,
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+1 -2
View File
@@ -266,10 +266,9 @@ def test_llm_span_recorder_provider_mapping(mock_time):
# ==================== Parent Span Tests ====================
@patch("hindsight_api.tracing._tracing_enabled", False)
def test_create_operation_span_disabled():
"""Test that create_operation_span returns no-op when tracing is disabled."""
# Tracing should be disabled by default (explicitly patched for test isolation)
# Tracing should be disabled by default
assert not is_tracing_enabled()
# Should return a no-op context manager
+12 -13
View File
@@ -73,10 +73,7 @@ def test_llm_wrapper_vertexai_adc_auth():
with patch.dict(
os.environ,
{
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project",
"HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY": "", # Clear SA key to test ADC path
},
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
clear=False,
):
from hindsight_api.config import clear_config_cache
@@ -99,10 +96,11 @@ def test_llm_wrapper_vertexai_adc_auth():
assert provider._gemini_client is not None
# Verify genai.Client was called with vertexai=True
call_kwargs = mock_client_cls.call_args.kwargs
assert call_kwargs["vertexai"] is True
assert call_kwargs["project"] == "test-project"
assert call_kwargs["location"] == "us-central1"
mock_client_cls.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
)
clear_config_cache()
@@ -143,11 +141,12 @@ def test_llm_wrapper_vertexai_sa_auth():
assert provider._gemini_client is not None
# Verify credentials were passed to genai.Client
call_kwargs = mock_client_cls.call_args.kwargs
assert call_kwargs["vertexai"] is True
assert call_kwargs["project"] == "test-project"
assert call_kwargs["location"] == "us-central1"
assert call_kwargs["credentials"] is mock_credentials
mock_client_cls.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
credentials=mock_credentials,
)
clear_config_cache()
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.13"
version = "0.4.11"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
-1
View File
@@ -266,7 +266,6 @@ pub fn recall(
max_tokens: chunk_max_tokens,
}),
entities: None,
source_facts: None,
})
} else {
None
+1 -1
View File
@@ -22,7 +22,7 @@ go get golang.org/x/net/context
Put the package under your project folder and add the following in import:
```go
import hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
import hindsight "github.com/vectorize-io/hindsight-client-go"
```
To use a proxy, set the environment variable `HTTP_PROXY`:
+4 -21
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.13
version: 0.4.11
servers:
- url: /
paths:
@@ -3226,8 +3226,6 @@ components:
$ref: '#/components/schemas/EntityIncludeOptions'
chunks:
$ref: '#/components/schemas/ChunkIncludeOptions'
source_facts:
$ref: '#/components/schemas/SourceFactsIncludeOptions'
title: IncludeOptions
ListDocumentsResponse:
description: Response model for list documents endpoint.
@@ -3726,10 +3724,6 @@ components:
additionalProperties:
$ref: '#/components/schemas/ChunkData'
nullable: true
source_facts:
additionalProperties:
$ref: '#/components/schemas/RecallResult'
nullable: true
required:
- results
title: RecallResponse
@@ -3795,11 +3789,6 @@ components:
type: string
nullable: true
type: array
source_fact_ids:
items:
type: string
nullable: true
type: array
required:
- id
- text
@@ -4094,6 +4083,9 @@ components:
description: Request model for retain endpoint.
example:
async: false
document_tags:
- user_a
- user_b
items:
- content: Alice works at Google
context: work
@@ -4156,15 +4148,6 @@ components:
- items_count
- success
title: RetainResponse
SourceFactsIncludeOptions:
description: Options for including source facts for observation-type results.
properties:
max_tokens:
default: 4096
description: Maximum tokens for source facts
title: Max Tokens
type: integer
title: SourceFactsIncludeOptions
TagItem:
description: Single tag with usage count.
properties:
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
// APIClient manages communication with the Hindsight HTTP API API v0.4.11
// 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.13
API version: 0.4.11
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/vectorize-io/hindsight/hindsight-clients/go
module github.com/vectorize-io/hindsight-client-go
go 1.18
-41
View File
@@ -1,41 +0,0 @@
package hindsight
import (
"net/http"
"time"
)
// NewAPIClientWithToken creates a new API client configured with a base URL and API token.
// The token is sent as a Bearer token in the Authorization header for all requests.
// Note: this uses http.DefaultClient which has no timeout. Use NewAPIClientWithTimeout
// to set a request timeout.
//
// Example:
//
// client := hindsight.NewAPIClientWithToken("https://api.example.com", "your-api-token")
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithToken(baseURL, token string) *APIClient {
cfg := NewConfiguration()
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
cfg.AddDefaultHeader("Authorization", "Bearer "+token)
return NewAPIClient(cfg)
}
// NewAPIClientWithTimeout creates a new API client configured with a base URL, API token,
// and a request timeout. Use 0 for no timeout.
//
// Example:
//
// client := hindsight.NewAPIClientWithTimeout("https://api.example.com", "your-api-token", 30*time.Second)
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithTimeout(baseURL, token string, timeout time.Duration) *APIClient {
cfg := NewConfiguration()
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
cfg.AddDefaultHeader("Authorization", "Bearer "+token)
cfg.HTTPClient = &http.Client{Timeout: timeout}
return NewAPIClient(cfg)
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// 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.13
API version: 0.4.11
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -47
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.13
API version: 0.4.11
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -21,7 +21,6 @@ var _ MappedNullable = &IncludeOptions{}
type IncludeOptions struct {
Entities NullableEntityIncludeOptions `json:"entities,omitempty"`
Chunks NullableChunkIncludeOptions `json:"chunks,omitempty"`
SourceFacts NullableSourceFactsIncludeOptions `json:"source_facts,omitempty"`
}
// NewIncludeOptions instantiates a new IncludeOptions object
@@ -125,48 +124,6 @@ func (o *IncludeOptions) UnsetChunks() {
o.Chunks.Unset()
}
// GetSourceFacts returns the SourceFacts field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *IncludeOptions) GetSourceFacts() SourceFactsIncludeOptions {
if o == nil || IsNil(o.SourceFacts.Get()) {
var ret SourceFactsIncludeOptions
return ret
}
return *o.SourceFacts.Get()
}
// GetSourceFactsOk returns a tuple with the SourceFacts field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *IncludeOptions) GetSourceFactsOk() (*SourceFactsIncludeOptions, bool) {
if o == nil {
return nil, false
}
return o.SourceFacts.Get(), o.SourceFacts.IsSet()
}
// HasSourceFacts returns a boolean if a field has been set.
func (o *IncludeOptions) HasSourceFacts() bool {
if o != nil && o.SourceFacts.IsSet() {
return true
}
return false
}
// SetSourceFacts gets a reference to the given NullableSourceFactsIncludeOptions and assigns it to the SourceFacts field.
func (o *IncludeOptions) SetSourceFacts(v SourceFactsIncludeOptions) {
o.SourceFacts.Set(&v)
}
// SetSourceFactsNil sets the value for SourceFacts to be an explicit nil
func (o *IncludeOptions) SetSourceFactsNil() {
o.SourceFacts.Set(nil)
}
// UnsetSourceFacts ensures that no value is present for SourceFacts, not even an explicit nil
func (o *IncludeOptions) UnsetSourceFacts() {
o.SourceFacts.Unset()
}
func (o IncludeOptions) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -183,9 +140,6 @@ func (o IncludeOptions) ToMap() (map[string]interface{}, error) {
if o.Chunks.IsSet() {
toSerialize["chunks"] = o.Chunks.Get()
}
if o.SourceFacts.IsSet() {
toSerialize["source_facts"] = o.SourceFacts.Get()
}
return toSerialize, nil
}

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