Compare commits

..
15 Commits
Author SHA1 Message Date
Nicolò Boschi f0c5153861 chore: apply ruff formatting to generate_changelog.py 2026-03-18 17:51:45 +01:00
Nicolò Boschi ef24828356 fix: add agno and hermes integration docs to version-0.4 for production build 2026-03-18 17:45:26 +01:00
Nicolò Boschi db7a0ad3a5 feat: independent versioning for integrations
- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle
2026-03-18 17:36:52 +01:00
Nicolò Boschi c10c9c89e9 docs: add 0.4.19 release blog post, Agno and Hermes integration pages (#608) 2026-03-18 17:35:54 +01:00
OctopusandPR Bot 1f1462a5f6 feat: upgrade MiniMax default model from M2.5 to M2.7 (#606)
* feat: upgrade MiniMax default model from M2.5 to M2.7

MiniMax has released MiniMax-M2.7, their latest model with a 1M context
window (up from 204K). This updates the default model across config,
docs, and examples. M2.5 remains fully compatible for users who prefer it.

- Update PROVIDER_DEFAULT_MODELS to MiniMax-M2.7
- Update .env.example and documentation references
- Add test_minimax_provider.py with M2.7 and backward compat tests

* chore: remove test file per review feedback

---------

Co-authored-by: PR Bot <[email protected]>
2026-03-18 17:15:20 +01:00
Nicolò Boschi 0727f2d069 Release v0.4.19
- Update version to 0.4.19 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-hermes, hindsight-agno, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-18 14:29:15 +01:00
Nicolò Boschi 72c25c97e3 feat(typescript-client): Deno compatibility (#607)
* feat(typescript-client): add Deno compatibility

- Switch build from tsc to tsup for dual CJS + ESM output with proper exports field
- Add deno_setup.ts preload that injects Jest-compatible globals (describe/test/expect) via @std/testing/bdd and @std/expect
- Fix generated client.gen.ts: exclude hey-api internal `client` field from RequestInit spread to avoid conflict with Deno.HttpClient
- Add test:deno npm script using --unstable-sloppy-imports and --preload
- Add test-typescript-client-deno CI job using denoland/setup-deno@v2 (v2.x)
- Update docs: rename page to TypeScript / JavaScript Client, add Deno installation section

* feat: add Deno compatibility to ai-sdk and chat integrations

- Switch ai-sdk and chat builds from tsc to tsup (ESM bundle, eliminates
  extension-less import issues in Deno)
- Add deno.json import map to ai-sdk redirecting 'vitest' to a custom
  vitest-compat.ts shim and bare npm specifiers to npm: URLs
- Add vitest-compat.ts shim implementing vi.fn()/vi.spyOn()/vi.mocked()
  using @std/expect's Symbol.for("@MOCK") interface so toHaveBeenCalledWith
  and other mock matchers work under Deno
- Add test:deno script to ai-sdk (all 30 tests pass under Deno)

* ci: add Deno test job for ai-sdk integration

Adds a new test-ai-sdk-integration-deno CI job that runs the ai-sdk
unit tests under Deno LTS, verifying Deno compatibility of the package.

* fix: remove broken link to non-existent n8n blog post in streamlit post

* fix: patch client.gen.ts for Deno compatibility during generation

Add a post-generation patch step to generate-clients.sh that removes
the hey-api internal 'client' field from the RequestInit spread in
client.gen.ts. Deno's Request constructor rejects 'client' because it
conflicts with the Deno.HttpClient option name.
2026-03-18 14:25:35 +01:00
BenandClaude Opus 4.6 8c378b981a feat: add Agno integration with Hindsight memory toolkit (#596)
* feat: add Agno integration with Hindsight memory toolkit

Add hindsight-agno package providing Hindsight memory tools (retain,
recall, reflect) as an Agno Toolkit, following the same pattern as
Agno's Mem0Tools. Includes per-user bank isolation, global config,
bank auto-creation, and memory_instructions() for system prompt
injection.

Also adds cookbook documentation page with architecture diagrams,
quick start examples, and configuration reference.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove n8n blog post, add Agno icon, bind to release process

- Remove n8n blog post from the agno integration branch
- Add Agno logo icon and map hindsight-agno SDK tag in CookbookGrid
- Add hindsight-agno to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove cookbook page (moved to hindsight-cookbook repo)

The Agno cookbook application now lives in
vectorize-io/hindsight-cookbook/applications/agno-memory.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 11:17:23 +01:00
Ben e2b19d3b38 blog: fix internal links in streamlit post (#605) 2026-03-17 15:33:08 -04:00
Ben 210a40665d blog: fix streamlit post slug and add cover image (#604) 2026-03-17 15:16:29 -04:00
Nicolò Boschi 28dac7c7f8 fix: prevent silent memory loss on consolidation LLM failure (#601)
* fix: prevent silent memory loss on consolidation LLM failure

When all LLM retries are exhausted during consolidation, memories were
being marked consolidated_at unconditionally, permanently excluding them
from future consolidation runs without producing any observations.

Fix with two complementary mechanisms:
- Adaptive batch splitting: on LLM failure, the batch is halved and
  retried recursively down to batch_size=1, recovering most transient
  failures (rate limits, Pydantic validation on long prompts) without
  operator intervention
- consolidation_failed_at column: only single-memory batches that still
  fail after all retries are marked here instead of consolidated_at, so
  they remain visible and retryable
- New API endpoint POST /v1/default/banks/{bank_id}/consolidation/retry-failed
  resets these memories for the next consolidation run

* chore: regenerate OpenAPI spec

* fix: rename consolidation endpoint from /retry-failed to /recover

* fix: add consolidation_failed_at column, adaptive batch splitting, and recovery API

- Migration a3b4c5d6e7f8: add consolidation_failed_at TIMESTAMPTZ column to
  memory_units with an index for efficient failure queries; properly chains off
  g7h8i9j0k1l2 (backsweep_orphan_observations)
- Consolidator: filter pending memories with consolidation_failed_at IS NULL
  so failed memories are not re-fetched in an infinite loop
- Consolidator: adaptive batch splitting — when a batch exhausts all 3 LLM
  retries, halve it and retry sub-batches recursively; only single-memory
  batches that also exhaust all retries get consolidation_failed_at set
- New tests (9 total) covering: adaptive splitting recovers all memories,
  larger batch splitting, single-memory permanent failure, exclusion from
  next run, partial batch failure, recover resets columns, recover returns
  0 when none failed, recover-then-consolidate succeeds, HTTP endpoint

* chore: regenerate Go, Python, TypeScript clients with recover consolidation endpoint

* feat: add Recover Consolidation action to bank Actions dropdown

* style: apply ruff formatting to http.py and config.py

* fix: handle consolidation scope in large batch test mock LLM

The mock LLM was returning {"facts": ...} for ALL calls including consolidation.
Consolidation doesn't use skip_validation=True so it expects a _ConsolidationBatchResponse
instance, not a raw dict. Before this PR consolidation silently swallowed the AttributeError
(failed=False was returned); now failed=True triggers adaptive splitting and timeouts.

Fix: return _ConsolidationBatchResponse() when scope=="consolidation".

* fix: restrict claude-agent-sdk to macOS platform only (no Linux wheel available)

Also fix pre-existing type errors: use setattr for XLM-RoBERTa monkey-patch
and add missing reranker_local_fp16/bucket_batching/batch_size fields to main.py config constructor.

* fix: add UV_INDEX_STRATEGY=unsafe-best-match to fix markupsafe cp314 wheel conflict

PyTorch CPU index serves markupsafe==3.0.3 with only cp314 wheels.
uv's default first-index strategy stops at the first index with any version
even if no compatible wheel exists. unsafe-best-match searches all indices
for the best compatible wheel, falling back to PyPI for markupsafe.

* fix: use explicit pytorch index to prevent markupsafe wheel conflict

Configure the pytorch CPU index as explicit=true in pyproject.toml so it is
ONLY used for torch (via [tool.uv.sources]). All other packages (including
markupsafe) are resolved exclusively from PyPI, preventing the pytorch index
from serving incompatible cp314-only wheels for non-pytorch packages.

Remove UV_INDEX and UV_INDEX_STRATEGY from CI workflow (no longer needed
since the index is now configured in pyproject.toml).

* ci: trigger CI run

* ci: retry trigger

* ci: trigger after remote URL fix

* ci: add workflow_dispatch to unblock manual trigger

* fix: remove empty env blocks left after UV_INDEX removal

* fix: add type: ignore for optional claude_agent_sdk imports (macOS-only)

* fix: correct type: ignore rules for claude_agent_sdk and fix utcnow deprecation
2026-03-17 20:15:33 +01:00
Ben f88f0a3b26 blog: Streamlit chatbot with persistent memory (#602)
* blog: add Streamlit chatbot with persistent memory post

* fix
2026-03-17 14:45:54 -04:00
Nicolò Boschi e4f8a157c2 feat(retain): verbatim, chunks modes and named retain strategies (#593)
* feat(retain): add verbatim extraction mode

Adds retain_extraction_mode="verbatim" that stores each chunk as-is
without LLM summarization. The LLM still runs to extract entities,
temporal info, and location for full indexability — only the fact text
is replaced with the original chunk content (one memory per chunk).

Useful for RAG-style indexing and benchmarks where original text
must be preserved in memory.

- Add "verbatim" to RETAIN_EXTRACTION_MODES in config.py
- Add VERBATIM_FACT_EXTRACTION_PROMPT with instructions to preserve text
- Add _collapse_to_verbatim() post-processing to enforce 1 fact/chunk
- Expose in bank config UI dropdown with updated description
- Update configuration.md docs with verbatim mode description
- Add unit test for _collapse_to_verbatim and integration test via LLM
- Fix pre-existing main.py CLI override missing new reranker fields
- Fix pre-existing cross_encoder.py ty type error via setattr

* refactor(retain): verbatim mode skips 'what' field entirely

Instead of asking the LLM to echo the chunk text back into 'what' and
then discarding it, verbatim mode now uses a dedicated schema
(VerbatimExtractedFact) that omits the 'what' field altogether.
The LLM only returns metadata (entities, temporal info, location, who),
saving output tokens and avoiding any risk of paraphrasing before the
backfill.

- Add VerbatimExtractedFact / VerbatimFactExtractionResponse models
- Verbatim mode skips causal-relations section (nothing to relate causally)
- _extract_facts_from_chunk: allow missing 'what' in verbatim mode,
  set combined_text="" (backfilled by _collapse_to_verbatim)
- Update verbatim prompt to say DO NOT include 'what'

* feat(retain): add index_only extraction mode

Zero-LLM retain mode: chunks are stored as-is with no LLM call, no
entity extraction, and no temporal indexing. Embeddings still run for
semantic search. User-provided entities via RetainContent.entities
are the sole source of entity data.

Early return placed before the batch-API check so no LLM queue or
concurrency locks are acquired.

- Add "index_only" to RETAIN_EXTRACTION_MODES
- Add _extract_facts_index_only() with pure Python chunking path
- Add to UI dropdown and update description
- Update configuration.md with index_only docs and table entry
- Add unit test asserting zero token usage and exact text preservation

* feat(retain): add named retain strategies

Allows mixing extraction modes in a single bank via named strategies.
Each strategy is a set of hierarchical config overrides (extraction_mode,
chunk_size, entity_labels, entities_allow_free_form, etc.) applied on
top of the resolved bank config at retain time.

- retain_strategies: dict of strategy_name → config overrides (bank config)
- retain_default_strategy: default strategy when none specified (bank config)
- strategy field on /retain request: per-call override
- apply_strategy() in config_resolver applies overrides via dataclasses.replace()
- strategy propagates through retain_batch_async → _retain_batch_async_internal
  and through the async worker task payload
- Any hierarchical field is overridable per strategy, including entity_labels
  and entities_allow_free_form
- Docs updated with strategy configuration example and RRF fairness note
- Unit test for apply_strategy covering overrides, unknown strategy, and
  non-hierarchical field filtering

* feat(retain): add per-item strategy and strategy tests

- Add `strategy` field to `MemoryItem` so individual items in a retain
  request can override the request-level strategy
- Add `strategy` field to `FileRetainMetadata` for per-file strategy
  override in file retain requests
- Group memory items by effective strategy in `api_retain`; each group
  is processed as a separate batch, results are aggregated
- Thread strategy through `submit_async_file_retain` →
  `_handle_file_convert_retain` → retain task payload
- Add `operation_ids` to `RetainResponse` for async requests with
  mixed per-item strategies
- Add `test_strategy_overrides_extraction_mode_for_index_only`: unit
  test verifying a named strategy with index_only bypasses the LLM
- Add `test_retain_request_per_item_strategy_field`: unit test for
  per-item strategy grouping logic

* feat(ui): add retain strategies and default strategy to bank config UI

- Add StrategiesEditor component: per-strategy cards with name input and
  JSON overrides textarea; supports add/remove; validates JSON inline
- Add Default Strategy text input (retain_default_strategy)
- Update RetainEdits type and retainSlice() to include both new fields
- Regenerate OpenAPI spec (retain_strategies, retain_default_strategy,
  per-item strategy on MemoryItem/FileRetainMetadata, operation_ids on
  RetainResponse)

* refactor(ui): move retain strategies into its own dedicated config section

* feat(ui): improve retain strategies UX and add strategy to document dialog

- Strategy form now includes entity section (free form toggle + entity labels editor)
- Default strategy selector moved outside tab panel, above strategy chips
- Strategy tabs redesigned with underline indicator style for clarity
- Remove strategy confirms with AlertDialog
- Fix tab re-render bug when typing strategy name (skipSyncRef)
- Add strategy field to Add New Document dialog (text + per-file for uploads)
- File upload collapsible uses same Document/Tags/Source tabbed layout
- API: validate empty strategy names in config_resolver
- api.ts: add strategy field to retain and uploadFiles types

* fix: forward strategy through HTTP layer and SDK; add integration test

- route.ts: extract and forward `strategy` from request body to retainBatch
- TypeScript SDK: accept and forward `strategy` in retainBatch options and per-item
- config_resolver.py: validate empty strategy name keys on update
- bank-config-view.tsx: merge entity fields into RetainStrategyForm, redesign strategy tabs with underline style, add confirmation dialog for removal, fix tab-reset-on-typing with skipSyncRef, move default strategy selector outside panel
- bank-selector.tsx: add strategy field to Add Document dialog (per-file in tabbed collapsible)
- test_retain.py: add end-to-end integration test verifying named strategy application (index_only = 0 LLM tokens)

* fix: regenerate TypeScript client with strategy field in RetainRequest/MemoryItem

- Regenerate OpenAPI spec to include strategy field in RetainRequest and MemoryItem
- Regenerate TypeScript client from updated spec
- Add strategy to MemoryItemInput interface
- Remove (item as any) cast now that strategy is properly typed

* rename: index_only extraction mode → chunks

* remove top-level strategy from RetainRequest; strategy is per-item only

* fix(clients): update Go and Python generated clients with strategy/operation_ids fields

* fix(ci): update hierarchical field count, add strategy to Rust MemoryItem initializers

* fix(go-client): minimal targeted YAML updates for strategy/operation_ids fields
2026-03-17 18:08:25 +01:00
BenandClaude Opus 4.6 ef90842f87 feat: hindsight-hermes integration for Hermes Agent (#600)
* feat: add hindsight-hermes integration for Hermes Agent

* chore: add Hermes docs page, icon, and release process bindings

- Add cookbook page for Hermes integration (synced with README)
- Add Hermes icon and map hindsight-hermes SDK tag in CookbookGrid
- Add cookbook entry to index.mdx
- Add hindsight-hermes to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-17 18:06:55 +01:00
Nicolò Boschi f68e2e2851 docs: add Best Practices unversioned page (#598)
* docs: revamp sidebar with icon grid components and language support

- Merge Clients and Integrations sections into the developer sidebar
  (removed top-level SDKs navbar item)
- Reorder sidebar: Architecture → API → Clients → Integrations → Hosting
- Unify icon system using react-icons (LuXxx/SiXxx) via customProps.icon
- Add uppercase section titles with increased spacing and reduced indentation
- Rename Node.js → "JavaScript / TypeScript" with TypeScript icon
- Add reusable IconGrid and SupportedGrids components (ClientsGrid,
  IntegrationsGrid, LLMProvidersGrid)
- Use grids in FAQ, Models, Overview, and Quick Start pages
- Convert developer/index.md, models.md, faq.md to MDX for JSX support

* docs: add Best Practices page as unversioned standalone page

- Add src/pages/best-practices.mdx covering core concepts (memory banks,
  taxonomy, memory types), bank configuration (missions, dispositions,
  entity labels), retain (formats, context, document_id, tags, observation
  scopes), recall (budget, tag filtering, include options), reflect
  (recall vs reflect decision, response_schema, auditing), mental models,
  and anti-patterns
- Add Resources section to sidebar with Best Practices and FAQ links
- Update generate-docs-skill.sh to include standalone pages (best-practices,
  faq) from src/pages/ into the agent skill references
- SKILL.md now surfaces best-practices.md as the recommended starting point

* fix: remove leftover merge conflict markers in DocSidebarItem Link

* fix: add missing lu-star, lu-circle-help, lu-file-text icons to sidebar map

* fix: remove duplicate LuFileText import

* fix: add Best Practices and FAQ to Resources navbar dropdown

* docs: hide right TOC and add manual TOC to best practices page

* docs: hide right TOC and add manual TOC to FAQ page

* fix: add lu-star icon to navbar item icon map

* fix: correct broken anchor in best practices TOC
2026-03-17 14:03:38 +01:00
340 changed files with 15460 additions and 1497 deletions
+2 -2
View File
@@ -20,10 +20,10 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: MiniMax configuration (204K context window)
# Example: MiniMax configuration (1M context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.5
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
+99
View File
@@ -0,0 +1,99 @@
name: Release Integration
on:
push:
tags:
- 'integrations/**'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
- name: Extract integration info
id: info
run: |
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
TAG="${GITHUB_REF#refs/tags/}"
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Integration: $INTEGRATION, Version: $VERSION"
- name: Detect integration type
id: type
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
else
echo "type=typescript" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
- name: Install uv
if: steps.type.outputs.type == 'python'
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build Python package
if: steps.type.outputs.type == 'python'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: uv build --out-dir dist
- name: Publish Python package to PyPI
if: steps.type.outputs.type == 'python'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
skip-existing: true
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm ci
- name: Build TypeScript package
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -207
View File
@@ -46,22 +46,10 @@ jobs:
working-directory: ./hindsight-all-slim
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Build hindsight-crewai
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -93,30 +81,12 @@ jobs:
packages-dir: ./hindsight-all-slim/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-embed/dist
skip-existing: true
- name: Publish hindsight-crewai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -128,10 +98,7 @@ jobs:
hindsight-api/dist/*
hindsight-all/dist/*
hindsight-all-slim/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
retention-days: 1
release-typescript-client:
@@ -183,153 +150,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-chat-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/chat
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/chat
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/chat
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/chat
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: chat-integration
path: hindsight-integrations/chat/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -587,7 +407,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -610,24 +430,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v8
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v8
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v8
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v8
with:
@@ -667,17 +469,9 @@ jobs:
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Chat Integration
cp artifacts/chat-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+138 -18
View File
@@ -3,6 +3,7 @@ name: CI
on:
pull_request:
branches: [ main ]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
@@ -97,6 +98,30 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
test-ai-sdk-integration-deno:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Run tests (Deno)
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
build-chat-integration:
runs-on: ubuntu-latest
@@ -199,7 +224,6 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -421,7 +445,6 @@ jobs:
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -484,7 +507,6 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -587,7 +609,6 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -686,6 +707,119 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-typescript-client-deno:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Build API
working-directory: ./hindsight-api-slim
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Install TypeScript client dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json
HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..120}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 120 ]; then
echo "API server failed to start after 120s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run TypeScript client tests (Deno)
working-directory: ./hindsight-clients/typescript
run: npm run test:deno
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
build-rust-cli-arm64:
runs-on: ubuntu-24.04-arm
@@ -719,7 +853,6 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -826,7 +959,6 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -931,7 +1063,6 @@ jobs:
HINDSIGHT_API_URL: http://localhost:8888
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1038,7 +1169,6 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1282,7 +1412,6 @@ jobs:
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1340,7 +1469,6 @@ jobs:
HINDSIGHT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_LLM_MODEL: google/gemini-2.5-flash-lite
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1396,7 +1524,6 @@ jobs:
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1530,7 +1657,6 @@ jobs:
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1603,9 +1729,6 @@ jobs:
verify-generated-files:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
@@ -1675,9 +1798,6 @@ jobs:
check-openapi-compatibility:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v6
with:
Generated
+139
View File
@@ -0,0 +1,139 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.17": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/expect@*": "1.0.18",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/testing@*": "1.0.17"
},
"jsr": {
"@std/[email protected]": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a",
"dependencies": [
"jsr:@std/assert@^1.0.19",
"jsr:@std/internal",
"jsr:@std/path"
]
},
"@std/[email protected]": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/[email protected]": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
"dependencies": [
"jsr:@std/assert@^1.0.17",
"jsr:@std/internal"
]
}
},
"workspace": {
"members": {
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
"npm:@hey-api/[email protected]",
"npm:@types/jest@29",
"npm:@types/node@20",
"npm:jest@29",
"npm:ts-jest@29",
"npm:tsup@^8.5.1",
"npm:typescript@5"
]
}
},
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
"npm:@radix-ui/react-checkbox@^1.3.3",
"npm:@radix-ui/react-dialog@^1.1.15",
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
"npm:@radix-ui/react-switch@^1.2.6",
"npm:@radix-ui/react-tabs@^1.1.13",
"npm:@radix-ui/react-tooltip@^1.2.8",
"npm:@tailwindcss/postcss@^4.1.17",
"npm:@tailwindcss/typography@~0.5.19",
"npm:@types/cytoscape@^3.21.9",
"npm:@types/node@^24.10.0",
"npm:@types/react-dom@^19.2.2",
"npm:@types/react@^19.2.2",
"npm:autoprefixer@^10.4.21",
"npm:class-variance-authority@~0.7.1",
"npm:clsx@^2.1.1",
"npm:cmdk@^1.1.1",
"npm:cytoscape-fcose@^2.2.0",
"npm:cytoscape@^3.33.1",
"npm:eslint-config-next@^16.0.1",
"npm:eslint-plugin-react-hooks@^7.0.1",
"npm:eslint-plugin-react@^7.37.5",
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
"npm:recharts@^3.5.1",
"npm:remark-gfm@^4.0.1",
"npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.4.0",
"npm:tailwindcss-animate@^1.0.7",
"npm:tailwindcss@^4.1.17",
"npm:[email protected]",
"npm:typescript-eslint@^8.50.0",
"npm:typescript@^5.9.3"
]
}
},
"hindsight-docs": {
"packageJson": {
"dependencies": [
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/theme-common@^3.9.2",
"npm:@docusaurus/theme-mermaid@^3.9.2",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@easyops-cn/docusaurus-search-local@~0.52.2",
"npm:@mdx-js/react@3",
"npm:clsx@2",
"npm:prism-react-renderer@^2.3.0",
"npm:raw-loader@^4.0.2",
"npm:react-dom@19",
"npm:react-icons@^5.6.0",
"npm:react@19",
"npm:redocusaurus@^2.5.0",
"npm:typescript@~5.6.2"
]
}
}
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.18
appVersion: "0.4.18"
version: 0.4.19
appVersion: "0.4.19"
keywords:
- ai
- memory
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.18"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.18"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.18"
__version__ = "0.4.19"
@@ -0,0 +1,52 @@
"""Add consolidation_failed_at column to memory_units for tracking persistent LLM failures.
When all LLM retries are exhausted on a single-memory batch, the memory is marked
with consolidation_failed_at instead of consolidated_at, so it is not silently lost
and can be retried later via the API.
Revision ID: a3b4c5d6e7f8
Revises: g7h8i9j0k1l2
Create Date: 2026-03-17
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a3b4c5d6e7f8"
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidation_failed_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Index to efficiently query memories that failed consolidation for a given bank
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_consolidation_failed
ON {schema}memory_units (bank_id, consolidation_failed_at)
WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
+107 -26
View File
@@ -10,7 +10,7 @@ import json
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Literal
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
@@ -425,6 +425,11 @@ class MemoryItem(BaseModel):
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
),
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
@field_validator("timestamp", mode="before")
@classmethod
@@ -491,6 +496,11 @@ class FileRetainMetadata(BaseModel):
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this file. Overrides the bank's default strategy. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
class FileRetainRequest(BaseModel):
@@ -544,7 +554,11 @@ class RetainResponse(BaseModel):
)
operation_id: str | None = Field(
default=None,
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.",
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.",
)
operation_ids: list[str] | None = Field(
default=None,
description="Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.",
)
usage: TokenUsage | None = Field(
default=None,
@@ -1314,6 +1328,14 @@ class ClearMemoryObservationsResponse(BaseModel):
deleted_count: int
class RecoverConsolidationResponse(BaseModel):
"""Response model for recovering failed consolidation."""
model_config = ConfigDict(json_schema_extra={"example": {"retried_count": 42}})
retried_count: int
class BankStatsResponse(BaseModel):
"""Response model for bank statistics endpoint."""
@@ -3888,6 +3910,34 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidation/recover",
response_model=RecoverConsolidationResponse,
summary="Recover failed consolidation",
description=(
"Reset all memories that were permanently marked as failed during consolidation "
"(after exhausting all LLM retries and adaptive batch splitting) so they are "
"picked up again on the next consolidation run. Does not delete any observations."
),
operation_id="recover_consolidation",
tags=["Banks"],
)
async def api_recover_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Reset consolidation-failed memories for recovery."""
try:
result = await app.state.memory.retry_failed_consolidation(bank_id, request_context=request_context)
return RecoverConsolidationResponse(retried_count=result["retried_count"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidation/recover: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/memories/{memory_id}/observations",
response_model=ClearMemoryObservationsResponse,
@@ -4121,7 +4171,7 @@ def _register_routes(app: FastAPI):
await bank_utils.get_bank_profile(pool, bank_id)
webhook_id = uuid.uuid4()
now = datetime.utcnow().isoformat() + "Z"
now = datetime.now(timezone.utc).isoformat()
row = await pool.fetchrow(
f"""
INSERT INTO {fq_table("webhooks")}
@@ -4441,10 +4491,13 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
try:
# Prepare contents for processing
contents = []
# Group items by strategy
strategy_groups: dict[str | None, list[dict]] = {}
for item in request.items:
content_dict = {"content": item.content}
effective = item.strategy
if effective not in strategy_groups:
strategy_groups[effective] = []
content_dict: dict = {"content": item.content}
if item.timestamp == "unset":
content_dict["event_date"] = None
elif item.timestamp:
@@ -4461,20 +4514,30 @@ def _register_routes(app: FastAPI):
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
contents.append(content_dict)
strategy_groups[effective].append(content_dict)
if request.async_:
# Async processing: queue task and return immediately
result = await app.state.memory.submit_async_retain(
bank_id, contents, document_tags=request.document_tags, request_context=request_context
)
# Async processing: one submit per strategy group
all_operation_ids = []
total_items_count = 0
for group_strategy, contents in strategy_groups.items():
result = await app.state.memory.submit_async_retain(
bank_id,
contents,
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
)
all_operation_ids.append(result["operation_id"])
total_items_count += result["items_count"]
return RetainResponse.model_validate(
{
"success": True,
"bank_id": bank_id,
"items_count": result["items_count"],
"items_count": total_items_count,
"async": True,
"operation_id": result["operation_id"],
"operation_id": all_operation_ids[0] if all_operation_ids else None,
"operation_ids": all_operation_ids if len(all_operation_ids) > 1 else None,
}
)
else:
@@ -4493,24 +4556,41 @@ def _register_routes(app: FastAPI):
),
)
# Synchronous processing: wait for completion (record metrics)
# Synchronous processing: one batch per strategy group, aggregate results
total_items_count = 0
total_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_tags=request.document_tags,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
for group_strategy, contents in strategy_groups.items():
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
total_items_count += len(contents)
if usage:
total_usage = TokenUsage(
input_tokens=total_usage.input_tokens + usage.input_tokens,
output_tokens=total_usage.output_tokens + usage.output_tokens,
total_tokens=total_usage.total_tokens + usage.total_tokens,
)
return RetainResponse.model_validate(
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
{
"success": True,
"bank_id": bank_id,
"items_count": total_items_count,
"async": False,
"usage": total_usage,
}
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -4673,6 +4753,7 @@ def _register_routes(app: FastAPI):
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
"strategy": file_meta.strategy,
}
file_items.append(item)
+12 -5
View File
@@ -267,6 +267,7 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
@@ -355,7 +356,7 @@ PROVIDER_DEFAULT_MODELS = {
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.5",
"minimax": "MiniMax-M2.7",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
@@ -443,9 +444,11 @@ DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction L
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
@@ -719,6 +722,8 @@ class HindsightConfig:
retain_extraction_mode: str
retain_mission: str | None
retain_custom_instructions: str | None
retain_default_strategy: str | None
retain_strategies: dict | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
@@ -849,6 +854,8 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
"retain_default_strategy",
"retain_strategies",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
@@ -1101,9 +1108,7 @@ class HindsightConfig:
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_local_fp16=os.getenv(
ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)
).lower()
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
in ("true", "1"),
reranker_local_bucket_batching=os.getenv(
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
@@ -1177,6 +1182,8 @@ class HindsightConfig:
),
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
@@ -10,7 +10,7 @@ multiple API servers.
import json
import logging
from dataclasses import asdict
from dataclasses import asdict, replace
from typing import Any
import asyncpg
@@ -239,6 +239,14 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
if empty_keys:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
@@ -273,3 +281,35 @@ class ConfigResolver:
)
logger.info(f"Reset bank config for {bank_id} to defaults")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
"""
strategies = config.retain_strategies or {}
if strategy_name not in strategies:
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
return config
overrides = strategies[strategy_name]
if not isinstance(overrides, dict):
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
return config
configurable = HindsightConfig.get_configurable_fields()
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
@@ -80,6 +80,7 @@ class _BatchLLMResult:
deletes: list[_DeleteAction] = field(default_factory=list)
obs_count: int = 0
prompt_chars: int = 0
failed: bool = False
@dataclass
@@ -219,6 +220,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
@@ -240,6 +242,7 @@ async def run_consolidation_job(
"observations_deleted": 0,
"actions_executed": 0,
"skipped": 0,
"memories_failed": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
@@ -257,6 +260,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
@@ -298,94 +302,141 @@ async def run_consolidation_job(
if memory_tags:
consolidated_tags.update(memory_tags)
async with pool.acquire() as conn:
# Determine observation_scopes for this batch. All memories in a batch share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Process llm_batch with adaptive splitting: on LLM failure, halve the sub-batch
# and retry, down to batch_size=1. Only if a single-memory batch still fails is
# the memory marked with consolidation_failed_at and excluded from future runs
# until explicitly retried via the API.
all_results: list[dict[str, Any]] = []
all_deleted = 0
succeeded_ids: list[Any] = []
failed_ids: list[Any] = []
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
pending: list[list[dict[str, Any]]] = [llm_batch]
while pending:
sub_batch = pending.pop(0)
batch_deleted: int = 0
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
results = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted = await _process_memory_batch(
async with pool.acquire() as conn:
# Determine observation_scopes for this sub-batch. All memories share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = sub_batch[0].get("observation_scopes") if sub_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
sub_deleted: int = 0
sub_llm_failed = False
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
sub_results: list[dict[str, Any]] = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted, pass_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
sub_deleted += pass_deleted
sub_llm_failed = sub_llm_failed or pass_failed
# Merge results: prefer non-skipped actions
if not sub_results:
sub_results = pass_results
else:
for i, (existing, new) in enumerate(zip(sub_results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
sub_results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
sub_results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
sub_results, sub_deleted, sub_llm_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
batch_deleted += pass_deleted
# Merge results: prefer non-skipped actions
if not results:
results = pass_results
else:
for i, (existing, new) in enumerate(zip(results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
results, batch_deleted = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
perf=perf,
config=config,
)
stats["observations_deleted"] += batch_deleted
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(m["id"],) for m in llm_batch],
)
all_deleted += sub_deleted
if sub_llm_failed and len(sub_batch) > 1:
# Split and retry with smaller batches
mid = len(sub_batch) // 2
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for sub-batch of {len(sub_batch)},"
f" splitting into {mid}/{len(sub_batch) - mid}"
)
pending[0:0] = [sub_batch[:mid], sub_batch[mid:]]
elif sub_llm_failed:
# batch_size=1 and still failing — mark as permanently failed for now
failed_ids.append(sub_batch[0]["id"])
all_results.append({"action": "failed"})
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for single memory"
f" {sub_batch[0]['id']}, marking consolidation_failed_at"
)
else:
succeeded_ids.extend(m["id"] for m in sub_batch)
all_results.extend(sub_results)
# Commit consolidated_at / consolidation_failed_at in a single DB round-trip
async with pool.acquire() as conn:
if succeeded_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in succeeded_ids],
)
if failed_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidation_failed_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in failed_ids],
)
stats["observations_deleted"] += all_deleted
results = all_results
# Checkpoint: abort if the operation (and thus the bank) was deleted mid-run.
if operation_id and not await memory_engine._check_op_alive(operation_id):
@@ -413,6 +464,8 @@ async def run_consolidation_job(
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
elif action == "failed":
stats["memories_failed"] += 1
# Per-LLM-batch log
llm_batch_time = time.time() - llm_batch_start
@@ -425,6 +478,7 @@ async def run_consolidation_job(
batch_created = stats["observations_created"] - snap_stats["observations_created"]
batch_updated = stats["observations_updated"] - snap_stats["observations_updated"]
batch_skipped = stats["skipped"] - snap_stats["skipped"]
batch_failed = stats["memories_failed"] - snap_stats["memories_failed"]
llm_calls_made = perf.llm_calls - snap_llm_calls
logger.info(
f"[CONSOLIDATION] bank={bank_id} llm_batch #{llm_batch_num}"
@@ -432,7 +486,8 @@ async def run_consolidation_job(
f" | {stats['memories_processed']}/{total_count} processed"
f" | {', '.join(timing_parts)}"
f" | created={batch_created} updated={batch_updated} skipped={batch_skipped}"
f" | input_tokens=~{input_tokens}"
+ (f" failed={batch_failed}" if batch_failed else "")
+ f" | input_tokens=~{input_tokens}"
f" | avg={llm_batch_time / len(llm_batch):.3f}s/memory"
)
@@ -584,7 +639,7 @@ async def _process_memory_batch(
perf: ConsolidationPerfLog | None = None,
config: Any = None,
obs_tags_override: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int]:
) -> tuple[list[dict[str, Any]], int, bool]:
"""
Process a batch of memories in a single LLM call.
@@ -747,7 +802,7 @@ async def _process_memory_batch(
else:
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
return results, deleted_count
return results, deleted_count, llm_result.failed
def _min_date(dates: "Any") -> "datetime | None":
@@ -1081,7 +1136,7 @@ async def _consolidate_batch_with_llm(
logger.error(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts, skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt))
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
async def _create_observation_directly(
@@ -633,7 +633,7 @@ class LLMProvider:
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -561,6 +561,7 @@ class MemoryEngine(MemoryEngineInterface):
contents = task_dict.get("contents", [])
document_tags = task_dict.get("document_tags")
operation_id = task_dict.get("operation_id") # For batch API crash recovery
strategy = task_dict.get("strategy")
logger.info(
f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}"
@@ -584,6 +585,7 @@ class MemoryEngine(MemoryEngineInterface):
document_tags=document_tags,
request_context=context,
operation_id=operation_id,
strategy=strategy,
outbox_callback=self._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
@@ -712,6 +714,8 @@ class MemoryEngine(MemoryEngineInterface):
retain_task_payload: dict[str, Any] = {"contents": retain_contents}
if document_tags:
retain_task_payload["document_tags"] = document_tags
if task_dict.get("strategy"):
retain_task_payload["strategy"] = task_dict["strategy"]
# Pass tenant/api_key context through to retain task
if task_dict.get("_tenant_id"):
@@ -1953,6 +1957,7 @@ class MemoryEngine(MemoryEngineInterface):
return_usage: bool = False,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
):
"""
Store multiple content items as memory units in ONE batch operation.
@@ -2110,6 +2115,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
# Outbox callback runs inside the last sub-batch's transaction so the
# webhook delivery row is committed atomically with the final retain data.
outbox_callback=outbox_callback if i == len(sub_batches) else None,
@@ -2134,6 +2140,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
strategy=strategy,
outbox_callback=outbox_callback,
)
@@ -2186,6 +2193,7 @@ class MemoryEngine(MemoryEngineInterface):
document_tags: list[str] | None = None,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
strategy: str | None = None,
) -> tuple[list[list[str]], "TokenUsage"]:
"""
Internal method for batch processing without chunking logic.
@@ -2218,6 +2226,13 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Apply strategy overrides: explicit strategy > bank default strategy
from hindsight_api.config_resolver import apply_strategy
effective_strategy = strategy or resolved_config.retain_default_strategy
if effective_strategy:
resolved_config = apply_strategy(resolved_config, effective_strategy)
# Create parent span for retain operation
with create_operation_span("retain", bank_id):
return await orchestrator.retain_batch(
@@ -3863,6 +3878,58 @@ class MemoryEngine(MemoryEngineInterface):
return {"deleted_count": count or 0}
async def retry_failed_consolidation(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Reset memories that previously failed consolidation so they are retried on the next
consolidation run.
Clears consolidation_failed_at (and consolidated_at) for all memories in the bank
that were marked as permanently failed after exhausting all LLM retries and adaptive
batch splitting. Does not delete any observations.
Args:
bank_id: Bank ID
request_context: Request context for authentication.
Returns:
Dictionary with count of memories queued for retry.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankWriteContext
ctx = BankWriteContext(
bank_id=bank_id, operation="retry_failed_consolidation", request_context=request_context
)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
count = await conn.fetchval(
f"""
SELECT COUNT(*) FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidation_failed_at IS NOT NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidation_failed_at = NULL, consolidated_at = NULL
WHERE bank_id = $1
AND consolidation_failed_at IS NOT NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
return {"retried_count": count or 0}
async def clear_observations_for_memory(
self,
bank_id: str,
@@ -7377,6 +7444,7 @@ class MemoryEngine(MemoryEngineInterface):
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
strategy: str | None = None,
) -> dict[str, Any]:
"""Submit a batch retain operation to run asynchronously.
@@ -7485,6 +7553,8 @@ class MemoryEngine(MemoryEngineInterface):
task_payload: dict[str, Any] = {"contents": sub_batch}
if document_tags:
task_payload["document_tags"] = document_tags
if strategy:
task_payload["strategy"] = strategy
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:
task_payload["_tenant_id"] = request_context.tenant_id
@@ -7599,6 +7669,8 @@ class MemoryEngine(MemoryEngineInterface):
"document_tags": document_tags or [],
"timestamp": item.get("timestamp"),
}
if item.get("strategy"):
task_payload["strategy"] = item["strategy"]
# Pass tenant_id and api_key_id through task payload
if request_context.tenant_id:
@@ -68,7 +68,7 @@ class ClaudeCodeLLM(LLMInterface):
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
@@ -141,7 +141,12 @@ class ClaudeCodeLLM(LLMInterface):
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
Exception: Re-raises API errors after retries exhausted.
"""
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
TextBlock,
query,
)
start_time = time.time()
@@ -331,7 +336,7 @@ class ClaudeCodeLLM(LLMInterface):
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from claude_agent_sdk import (
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
@@ -6,7 +6,7 @@ This provider handles all OpenAI API-compatible models including:
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.5 models with 204K context window
- MiniMax: MiniMax-M2.7 models with 1M context window
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -48,7 +48,7 @@ class OpenAICompatibleLLM(LLMInterface):
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.5 models via OpenAI-compatible API (https://api.minimax.io/v1)
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
"""
def __init__(
@@ -332,6 +332,43 @@ class FactExtractionResponseNoCausal(BaseModel):
facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
class VerbatimExtractedFact(BaseModel):
"""
Schema for verbatim extraction mode.
Omits 'what' entirely — the original chunk text is used as fact_text in code.
The LLM only extracts metadata: entities, temporal info, location, people.
"""
model_config = ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": ["when", "where", "who", "fact_type"]},
)
when: str = Field(description="When it happened. 'N/A' if unknown.")
where: str = Field(description="Location if relevant. 'N/A' if none.")
who: str = Field(description="People involved with relationships. 'N/A' if general.")
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
class VerbatimFactExtractionResponse(BaseModel):
"""Response for verbatim extraction mode (one entry per chunk, no fact text)."""
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
@@ -552,6 +589,27 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
examples="", # No examples for custom mode
)
# Verbatim mode: preserve the original text exactly, but still extract metadata
_VERBATIM_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
VERBATIM MODE — Extract metadata only
══════════════════════════════════════════════════════════════════════════
The original text will be stored as-is in code. Your ONLY job is to extract metadata.
RULES:
- Produce EXACTLY ONE entry per input chunk.
- DO NOT include a "what" field — it is not part of the output schema.
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
extraction_guidelines=_VERBATIM_GUIDELINES,
examples="",
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -770,6 +828,10 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
elif extraction_mode == "verbose":
prompt = VERBOSE_FACT_EXTRACTION_PROMPT
elif extraction_mode == "verbatim":
prompt = VERBATIM_FACT_EXTRACTION_PROMPT.format(
retain_mission_section=retain_mission_section,
)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
@@ -777,7 +839,11 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
)
# Add causal relationships section if enabled
if extract_causal_links:
# Verbatim mode never uses causal relations (no fact text to relate causally)
if extraction_mode == "verbatim":
base_fact_class = VerbatimExtractedFact
base_response_class = VerbatimFactExtractionResponse
elif extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
@@ -1012,8 +1078,10 @@ async def _extract_facts_from_chunk(
if not what:
what = get_value("factual_core")
if not what:
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
if extraction_mode != "verbatim":
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
@@ -1046,19 +1114,23 @@ async def _extract_facts_from_chunk(
fact_kind = "conversation"
# Build combined fact text from the 4 dimensions: what | when | who | why
# In verbatim mode, leave combined_text empty — _collapse_to_verbatim backfills it
fact_data = {}
combined_parts = [what]
if extraction_mode == "verbatim":
combined_text = ""
else:
combined_parts = [what]
if when:
combined_parts.append(f"When: {when}")
if when:
combined_parts.append(f"When: {when}")
if who:
combined_parts.append(f"Involving: {who}")
if who:
combined_parts.append(f"Involving: {who}")
if why:
combined_parts.append(why)
if why:
combined_parts.append(why)
combined_text = " | ".join(combined_parts)
combined_text = " | ".join(combined_parts)
# Add temporal fields
# For events: occurred_start/occurred_end (when the event happened)
@@ -1889,6 +1961,52 @@ async def extract_facts_from_contents_batch_api(
return extracted_facts, chunks_metadata, total_usage
def _extract_facts_chunks(
contents: list[RetainContent],
config,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
chunks mode: no LLM call, no entity extraction.
Each chunk becomes one memory unit with the raw text as fact_text.
User-provided entities from RetainContent.entities are picked up downstream
by entity_processing.py — they are the sole source of entity data in this mode.
"""
extracted_facts: list[ExtractedFactType] = []
chunks_metadata: list[ChunkMetadata] = []
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk,
fact_count=1,
content_index=content_index,
chunk_index=global_chunk_idx,
)
)
extracted_facts.append(
ExtractedFactType(
fact_text=chunk,
fact_type="world",
entities=[],
content_index=content_index,
chunk_index=global_chunk_idx,
context=content.context,
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
)
global_chunk_idx += 1
_add_temporal_offsets(extracted_facts, contents)
return extracted_facts, chunks_metadata, TokenUsage()
async def extract_facts_from_contents(
contents: list[RetainContent],
llm_config,
@@ -1924,6 +2042,11 @@ async def extract_facts_from_contents(
if not contents:
return [], [], TokenUsage()
# chunks mode: skip LLM entirely, store each chunk as-is
# Must come before the batch-API check so no LLM queue/locks are acquired
if config.retain_extraction_mode == "chunks":
return _extract_facts_chunks(contents, config)
# Route to batch API if enabled
if config.retain_batch_enabled:
return await extract_facts_from_contents_batch_api(
@@ -2013,15 +2136,46 @@ async def extract_facts_from_contents(
global_fact_idx += 1
fact_idx_in_content += 1
# Step 4: Add time offsets to preserve ordering within each content
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
if config.retain_extraction_mode == "verbatim":
extracted_facts = _collapse_to_verbatim(extracted_facts, chunks_metadata)
# Step 5: Add time offsets to preserve ordering within each content
_add_temporal_offsets(extracted_facts, contents)
# Step 5: Auto-tag facts from label groups with tag=True
# Step 6: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
return extracted_facts, chunks_metadata, total_usage
def _collapse_to_verbatim(facts: list[ExtractedFactType], chunks: list[ChunkMetadata]) -> list[ExtractedFactType]:
"""
For verbatim mode: ensure one fact per chunk with the original chunk text preserved.
The LLM prompt asks for exactly one fact per chunk, but if it returns more,
this collapses them: keeps the first fact as representative, overrides its
fact_text with the raw chunk text, and merges entities from any extra facts.
"""
chunk_text_map = {c.chunk_index: c.chunk_text for c in chunks}
seen: dict[int, ExtractedFactType] = {}
result: list[ExtractedFactType] = []
for fact in facts:
if fact.chunk_index not in seen:
fact.fact_text = chunk_text_map.get(fact.chunk_index, fact.fact_text)
seen[fact.chunk_index] = fact
result.append(fact)
else:
# Merge entities from extra facts into the representative
representative = seen[fact.chunk_index]
for entity in fact.entities:
if entity not in representative.entities:
representative.entities.append(entity)
return result
def _parse_datetime(date_str: str):
"""Parse ISO datetime string."""
from dateutil import parser as date_parser
+2
View File
@@ -257,6 +257,8 @@ def main():
retain_extraction_mode=config.retain_extraction_mode,
retain_mission=config.retain_mission,
retain_custom_instructions=config.retain_custom_instructions,
retain_default_strategy=config.retain_default_strategy,
retain_strategies=config.retain_strategies,
retain_batch_tokens=config.retain_batch_tokens,
retain_entity_lookup=config.retain_entity_lookup,
retain_batch_enabled=config.retain_batch_enabled,
+12 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.4.18"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -55,7 +55,7 @@ dependencies = [
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.6", # Account takeover vulnerability fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27",
"claude-agent-sdk>=0.1.27; sys_platform == 'darwin'",
]
[project.optional-dependencies]
@@ -168,9 +168,16 @@ quote-style = "double"
indent-style = "space"
[tool.uv]
# Allow uv to search all configured indexes for packages, not just the first one
# This prevents dependency resolution failures when using pytorch index + PyPI
index-strategy = "unsafe-best-match"
# Use explicit index for PyTorch to prevent the pytorch index from serving
# non-pytorch packages (e.g. markupsafe) with incompatible wheels
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Route torch to the CPU-only PyTorch index; everything else uses PyPI
torch = { index = "pytorch-cpu" }
[tool.ty]
# Type checking configuration
@@ -0,0 +1,476 @@
"""Tests for consolidation failure handling: adaptive batch splitting, consolidation_failed_at,
and the recovery API.
These tests use a mock LLM to simulate LLM failures deterministically, without making real
API calls. All tests insert memories directly into the database to bypass retain's LLM calls
and focus exclusively on the consolidation code paths.
"""
import uuid
from unittest.mock import MagicMock
import pytest
import pytest_asyncio
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.providers.mock_llm import MockLLM
from hindsight_api.engine.task_backend import SyncTaskBackend
@pytest_asyncio.fixture(scope="function")
async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""MemoryEngine with mock LLM.
Migrations are already applied by the session-scoped pg0_db_url fixture, so
run_migrations=False avoids advisory-lock serialization overhead per test.
"""
mem = MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="mock",
memory_llm_api_key="",
memory_llm_model="mock",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False,
task_backend=SyncTaskBackend(),
skip_llm_verification=True,
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
from hindsight_api.config import _get_raw_config
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
def _make_failing_mock_llm(*, fail_first_n: int = 999) -> MockLLM:
"""Return a MockLLM that raises ValueError for the first `fail_first_n` consolidation calls."""
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
call_count = 0
def callback(messages, scope):
nonlocal call_count
if scope == "consolidation":
call_count += 1
if call_count <= fail_first_n:
raise ValueError(f"Simulated LLM failure (call {call_count})")
# Return empty response — no creates/updates/deletes
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
return _ConsolidationBatchResponse()
mock_llm.set_response_callback(callback)
return mock_llm
def _make_always_success_mock_llm() -> MockLLM:
"""Return a MockLLM that always succeeds with an empty consolidation response."""
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
def callback(messages, scope):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
return _ConsolidationBatchResponse()
mock_llm.set_response_callback(callback)
return mock_llm
def _inject_mock_llm(memory: MemoryEngine, mock_llm: MockLLM) -> None:
"""Replace memory._consolidation_llm_config with a wrapper that returns mock_llm from with_config."""
wrapper = MagicMock()
wrapper.with_config.return_value = mock_llm
memory._consolidation_llm_config = wrapper
async def _insert_memories(conn, bank_id: str, texts: list[str]) -> list[uuid.UUID]:
"""Insert experience memories directly, bypassing LLM-based retain."""
ids = []
for text in texts:
mem_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, created_at)
VALUES ($1, $2, $3, 'experience', now())
""",
mem_id,
bank_id,
text,
)
ids.append(mem_id)
return ids
class TestAdaptiveBatchSplitting:
"""Verify that a failing batch is halved and retried until batch_size=1 succeeds."""
@pytest.mark.asyncio
async def test_splitting_recovers_all_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""When a batch of 2 fails, both are retried individually and succeed."""
bank_id = f"test-split-recovery-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
mem_ids = await _insert_memories(
conn,
bank_id,
[
"Alice runs marathons every spring.",
"Alice trained for six months for her last race.",
],
)
# Exhaust all 3 retries for batch=2 (calls 1-3 fail), then each batch=1 succeeds (calls 4-5)
mock_llm = _make_failing_mock_llm(fail_first_n=3)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "completed"
assert result["memories_processed"] == 2
assert result["memories_failed"] == 0
# Both memories must have consolidated_at set and consolidation_failed_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, consolidated_at, consolidation_failed_at
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'experience'
""",
bank_id,
)
assert len(rows) == 2
for row in rows:
assert row["consolidated_at"] is not None, f"Memory {row['id']} should have consolidated_at set"
assert row["consolidation_failed_at"] is None, (
f"Memory {row['id']} should NOT have consolidation_failed_at set"
)
# LLM called 5 times: 3 retries failed (batch=2) + 1 succeeded (batch=1) + 1 succeeded (batch=1)
consolidation_calls = [c for c in mock_llm.get_mock_calls() if c["scope"] == "consolidation"]
assert len(consolidation_calls) == 5
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_splitting_with_larger_batch(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A batch of 4 that always fails at size>1 resolves to 4 individual calls."""
bank_id = f"test-split-large-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
await _insert_memories(
conn,
bank_id,
[
"Bob plays chess competitively.",
"Bob won a regional chess tournament.",
"Bob practices tactics every morning.",
"Bob coaches youth chess on weekends.",
],
)
# Exhaust all 3 retries for batch=4 (calls 1-3 fail), then both batch=2 halves succeed
# (calls 4-5). This verifies that halving once is sufficient when batch=2 works.
mock_llm = _make_failing_mock_llm(fail_first_n=3)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_processed"] == 4
assert result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
assert all(r["consolidated_at"] is not None for r in rows)
assert all(r["consolidation_failed_at"] is None for r in rows)
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
class TestConsolidationFailedAt:
"""Verify that consolidation_failed_at is set — and consolidated_at is NOT — when all retries fail."""
@pytest.mark.asyncio
async def test_single_memory_permanent_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A single memory that exhausts all LLM retries gets consolidation_failed_at, not consolidated_at."""
bank_id = f"test-perm-fail-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Carol enjoys painting watercolors."])
# Always fail
mock_llm = _make_failing_mock_llm(fail_first_n=999)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_failed"] == 1
assert result["memories_processed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is None, "consolidated_at must NOT be set for a permanently failed memory"
assert row["consolidation_failed_at"] is not None, "consolidation_failed_at must be set"
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_failed_memory_excluded_from_next_run(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A memory marked consolidation_failed_at is not re-processed on the next consolidation run."""
bank_id = f"test-excluded-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Dave collects vinyl records."])
# Manually stamp consolidation_failed_at to simulate a prior failed run
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
mem_id,
)
# Even with a healthy LLM, the memory should be skipped
mock_llm = _make_always_success_mock_llm()
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
# No unconsolidated memories to pick up (consolidation_failed_at ≠ NULL, consolidated_at = NULL
# but the SELECT filters on consolidated_at IS NULL AND fact_type IN ('experience','world'))
assert result["status"] in ("no_new_memories", "completed")
if result["status"] == "completed":
assert result["memories_processed"] == 0
# Memory still has consolidation_failed_at set and consolidated_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is None
assert row["consolidation_failed_at"] is not None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_partial_batch_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""In a batch of 2, if only the first individual retry fails, the second still succeeds."""
bank_id = f"test-partial-fail-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
mem_ids = await _insert_memories(
conn,
bank_id,
[
"Eve speaks three languages fluently.",
"Eve learned Japanese in two years.",
],
)
# Exhaust 3 retries for batch=2 (calls 1-3), exhaust 3 retries for first batch=1 (calls 4-6),
# second batch=1 succeeds (call 7)
mock_llm = _make_failing_mock_llm(fail_first_n=6)
_inject_mock_llm(memory_no_llm_verify, mock_llm)
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert result["memories_processed"] == 2
assert result["memories_failed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
rows = {
str(r["id"]): r
for r in await conn.fetch(
"SELECT id, consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
}
# One should have failed, one should have succeeded
failed = [r for r in rows.values() if r["consolidation_failed_at"] is not None]
succeeded = [r for r in rows.values() if r["consolidated_at"] is not None]
assert len(failed) == 1
assert len(succeeded) == 1
# They must be different memories
assert str(failed[0]["id"]) != str(succeeded[0]["id"])
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
class TestRecoverConsolidation:
"""Verify the retry_failed_consolidation() method and the /consolidation/recover endpoint."""
@pytest.mark.asyncio
async def test_recover_resets_failed_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""retry_failed_consolidation resets consolidation_failed_at and consolidated_at."""
bank_id = f"test-recover-reset-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
ids = await _insert_memories(
conn,
bank_id,
[
"Frank is a competitive cyclist.",
"Frank completed the Tour de France route.",
],
)
# Mark both as failed
for mem_id in ids:
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
mem_id,
)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert result["retried_count"] == 2
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
assert all(r["consolidation_failed_at"] is None for r in rows), "consolidation_failed_at must be cleared"
assert all(r["consolidated_at"] is None for r in rows), "consolidated_at must also be cleared"
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_returns_zero_when_none_failed(self, memory_no_llm_verify: MemoryEngine, request_context):
"""retry_failed_consolidation returns 0 when no memories have failed."""
bank_id = f"test-recover-zero-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert result["retried_count"] == 0
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_then_consolidate_succeeds(self, memory_no_llm_verify: MemoryEngine, request_context):
"""After recovery, the memory is picked up by the next consolidation run."""
bank_id = f"test-recover-consolidate-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
(mem_id,) = await _insert_memories(conn, bank_id, ["Grace is an expert rock climber."])
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
# Recover
recover_result = await memory_no_llm_verify.retry_failed_consolidation(
bank_id, request_context=request_context
)
assert recover_result["retried_count"] == 1
# Now consolidate with a healthy LLM
mock_llm = _make_always_success_mock_llm()
_inject_mock_llm(memory_no_llm_verify, mock_llm)
run_result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=bank_id,
request_context=request_context,
)
assert run_result["memories_processed"] == 1
assert run_result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
)
assert row["consolidated_at"] is not None, "Memory should be consolidated after recovery"
assert row["consolidation_failed_at"] is None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recover_endpoint_via_http(self, memory_no_llm_verify: MemoryEngine, request_context):
"""The POST /consolidation/recover endpoint returns the correct retried_count."""
import httpx
from hindsight_api.api.http import create_app
bank_id = f"test-recover-http-{uuid.uuid4().hex[:8]}"
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
async with memory_no_llm_verify._pool.acquire() as conn:
ids = await _insert_memories(
conn,
bank_id,
["Henry is a professional chef.", "Henry trained at Le Cordon Bleu."],
)
for mem_id in ids:
await conn.execute(
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
)
app = create_app(memory_no_llm_verify, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/v1/default/banks/{bank_id}/consolidation/recover")
assert response.status_code == 200
body = response.json()
assert body["retried_count"] == 2
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 17
assert len(configurable) == 19
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -149,6 +149,16 @@ class TestLargeBatchRetain:
call_tracker = {"count": 0, "facts": 0}
async def mock_llm_call(*args, **kwargs):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
# Consolidation calls expect a _ConsolidationBatchResponse (not a raw dict),
# because consolidation does NOT use skip_validation=True.
if kwargs.get("scope") == "consolidation":
return_usage = kwargs.get("return_usage", False)
if return_usage:
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
return _ConsolidationBatchResponse()
call_tracker["count"] += 1
# Extract the content from the user message to generate proportional facts
@@ -157,7 +167,7 @@ class TestLargeBatchRetain:
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.5)
call_tracker["facts"] += len(mock_facts)
# Return a dict (parsed JSON) since skip_validation=True but the code expects a dict
# Return a dict (parsed JSON) — fact extraction uses skip_validation=True
response_dict = {"facts": mock_facts}
return_usage = kwargs.get("return_usage", False)
@@ -236,6 +246,14 @@ class TestLargeBatchRetain:
logger.info(f"Created {num_items} items with {actual_total_chars:,} chars (should trigger chunking)")
async def mock_llm_call(*args, **kwargs):
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
if kwargs.get("scope") == "consolidation":
return_usage = kwargs.get("return_usage", False)
if return_usage:
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
return _ConsolidationBatchResponse()
messages = kwargs.get("messages", args[0] if args else [])
user_msg = messages[-1]["content"] if messages else ""
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.0)
+370 -19
View File
@@ -1,11 +1,13 @@
"""
Test retain function and chunk storage.
"""
import pytest
import logging
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
from datetime import datetime, timedelta, timezone
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@@ -60,7 +62,7 @@ async def test_retain_with_chunks(memory, request_context):
request_context=request_context,
)
print(f"\n=== Recall Results (with chunks) ===")
print("\n=== Recall Results (with chunks) ===")
print(f"Found {len(result.results)} results")
assert len(result.results) > 0, "Should find facts about Alice"
@@ -149,7 +151,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
request_context=request_context,
)
print(f"\n=== Recall Results ===")
print("\n=== Recall Results ===")
print(f"Found {len(result.results)} facts")
# Extract the order of entities mentioned in facts
@@ -421,7 +423,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
# Verify it's the historical date, not today
assert mentioned_dt.year == 2020, f"mentioned_at should be 2020, got {mentioned_dt.year}"
print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020")
print("✓ Test passed: Historical conversation correctly ingested with event_date=2020")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -489,15 +491,15 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
# If occurred_start is set, it means the LLM extracted it
# In this case, log it but don't fail (LLM behavior can vary)
print(f"⚠ LLM extracted occurred_start: {fact.occurred_start}")
print(f" This test expects None for present-tense observations")
print(" This test expects None for present-tense observations")
else:
print(f"✓ occurred_start is correctly None (not defaulted to mentioned_at)")
print("✓ occurred_start is correctly None (not defaulted to mentioned_at)")
if fact.occurred_end is not None:
print(f"⚠ LLM extracted occurred_end: {fact.occurred_end}")
print(f" This test expects None for present-tense observations")
print(" This test expects None for present-tense observations")
else:
print(f"✓ occurred_end is correctly None (not defaulted to mentioned_at)")
print("✓ occurred_end is correctly None (not defaulted to mentioned_at)")
# At least verify they're not equal to mentioned_at if they are set
if fact.occurred_start is not None:
@@ -513,7 +515,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
f"occurred_start={occurred_start_dt}, mentioned_at={mentioned_dt}"
)
print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
print("✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -585,7 +587,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
else:
print(f"⚠ LLM did not extract date from context, fell back to now(): {mentioned_dt}")
print(f"✓ mentioned_at is always set (never None)")
print("✓ mentioned_at is always set (never None)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -851,8 +853,8 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
assert len(result.results) > 0, "Should recall stored facts"
print(f"✓ Successfully stored and retrieved facts")
print(f" (Note: Metadata support depends on API implementation)")
print("✓ Successfully stored and retrieved facts")
print(" (Note: Metadata support depends on API implementation)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -954,7 +956,7 @@ async def test_mixed_content_batch(memory, request_context):
short_units = len(unit_ids[0])
long_units = len(unit_ids[1])
print(f"✓ Mixed batch processed successfully")
print("✓ Mixed batch processed successfully")
print(f" Short content: {short_units} units")
print(f" Long content: {long_units} units")
@@ -1356,7 +1358,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
if truncated_chunks:
print(f" {len(truncated_chunks)} chunks were truncated due to token limit")
else:
print(f" No chunks were truncated (content within limit)")
print(" No chunks were truncated (content within limit)")
else:
print("✓ No chunks returned (may be under token limit)")
@@ -2210,9 +2212,10 @@ async def test_custom_extraction_mode():
custom guidelines while keeping structural parts intact.
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache, _get_raw_config
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
@@ -2284,7 +2287,7 @@ If the text contains both Italian and English content, extract ONLY the Italian
if found_english_only:
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
logger.warning(f" Facts: {all_facts_text}")
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
logger.warning(" This may indicate the LLM is not strictly following language-specific custom guidelines")
# Log but don't fail - LLM behavior can vary
else:
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
@@ -2315,6 +2318,213 @@ If the text contains both Italian and English content, extract ONLY the Italian
clear_config_cache()
def test_apply_strategy():
"""
Unit test for apply_strategy:
- Known strategy applies overrides on top of resolved config
- Unknown strategy returns config unchanged with a warning
- Non-hierarchical fields in a strategy are silently ignored
- entity_labels and entities_allow_free_form are overridable
"""
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
clear_config_cache()
base_config = _get_raw_config()
strategies = {
"documents": {
"retain_extraction_mode": "chunks",
"retain_chunk_size": 800,
"entities_allow_free_form": False,
},
"bad_field": {
"database_url": "should-be-ignored", # static field, not hierarchical
"retain_extraction_mode": "verbose",
},
}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
# Known strategy: overrides applied
result = apply_strategy(config_with_strategies, "documents")
assert result.retain_extraction_mode == "chunks"
assert result.retain_chunk_size == 800
assert result.entities_allow_free_form is False
# Non-hierarchical field silently ignored, hierarchical one applied
result2 = apply_strategy(config_with_strategies, "bad_field")
assert result2.retain_extraction_mode == "verbose"
assert result2.database_url == base_config.database_url # unchanged
# Unknown strategy: config returned unchanged
result3 = apply_strategy(config_with_strategies, "nonexistent")
assert result3.retain_extraction_mode == base_config.retain_extraction_mode
def test_collapse_to_verbatim_single_fact_per_chunk():
"""
Unit test for _collapse_to_verbatim:
- One fact per chunk → text overridden with original chunk text
- Two facts from same chunk → collapsed to one, entities merged
"""
from hindsight_api.engine.retain.fact_extraction import _collapse_to_verbatim
from hindsight_api.engine.retain.types import ChunkMetadata, ExtractedFact
chunks = [
ChunkMetadata(chunk_text="Alice went to Paris.", fact_count=1, content_index=0, chunk_index=0),
ChunkMetadata(chunk_text="Bob fixed the bug yesterday.", fact_count=2, content_index=0, chunk_index=1),
]
facts = [
ExtractedFact(fact_text="LLM paraphrase of Alice in Paris", fact_type="world", entities=["Alice", "Paris"], chunk_index=0, content_index=0),
ExtractedFact(fact_text="LLM first fact about Bob", fact_type="world", entities=["Bob"], chunk_index=1, content_index=0),
ExtractedFact(fact_text="LLM second fact about bug", fact_type="world", entities=["bug"], chunk_index=1, content_index=0),
]
result = _collapse_to_verbatim(facts, chunks)
assert len(result) == 2, "Should produce exactly one fact per chunk"
# Chunk 0: text overridden with original chunk text
assert result[0].fact_text == "Alice went to Paris.", "Text must be the raw chunk text"
assert result[0].entities == ["Alice", "Paris"]
# Chunk 1: collapsed to one fact, entities merged from both LLM facts
assert result[1].fact_text == "Bob fixed the bug yesterday.", "Text must be the raw chunk text"
assert "Bob" in result[1].entities
assert "bug" in result[1].entities
def test_chunks_extraction_mode():
"""
Unit test for chunks mode: no LLM, chunks stored as-is, zero token usage.
"""
import asyncio
import os
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "chunks"
clear_config_cache()
contents = [
RetainContent(
content="Alice joined the infrastructure team on March 5, 2024.",
event_date=datetime(2024, 3, 10, tzinfo=timezone.utc),
entities=[{"text": "Alice"}, {"text": "infrastructure team"}],
),
RetainContent(content="Bob fixed the critical bug in the payment service."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # Must not be called
agent_name="TestAgent",
config=_get_raw_config(),
)
)
# One fact per chunk (both contents fit in one chunk each)
assert len(facts) == len(chunks) == 2
# Text preserved exactly
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
# No LLM-extracted entities (user-provided entities handled downstream)
assert facts[0].entities == []
assert facts[1].entities == []
# Zero token usage
assert usage.total_tokens == 0
logger.info("✓ chunks mode: no LLM call, chunks stored as-is, zero token usage")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_verbatim_extraction_mode():
"""
Integration test for verbatim extraction mode.
Verifies that:
1. Each chunk produces exactly one fact
2. The fact text is the original chunk text, not a paraphrase
3. Entities are still extracted by the LLM
4. Temporal info (occurred_start) is still extracted
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
try:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "verbatim"
clear_config_cache()
text = (
"Alice joined the infrastructure team on March 5, 2024. "
"She holds a CKA certification and has 5 years of Kubernetes experience."
)
llm_config = LLMConfig.for_memory()
contents = [RetainContent(content=text, event_date=datetime(2024, 3, 10, tzinfo=timezone.utc), context="onboarding notes")]
facts, chunks, _ = await extract_facts_from_contents(
contents=contents,
llm_config=llm_config,
agent_name="TestAgent",
config=_get_raw_config(),
)
logger.info(f"Verbatim mode extracted {len(facts)} facts from {len(chunks)} chunks")
for i, f in enumerate(facts):
logger.info(f" fact[{i}]: {f.fact_text!r} entities={f.entities}")
# One fact per chunk
assert len(facts) == len(chunks), "Verbatim mode must produce exactly one fact per chunk"
# Text must match the original chunk exactly
for fact, chunk in zip(facts, chunks):
assert fact.fact_text == chunk.chunk_text, (
f"fact_text must equal original chunk text.\n"
f" expected: {chunk.chunk_text!r}\n"
f" got: {fact.fact_text!r}"
)
# Entities should still be extracted
all_entities = [e for f in facts for e in f.entities]
assert any("alice" in e.lower() for e in all_entities), (
f"Expected entity 'Alice' to be extracted. Entities: {all_entities}"
)
logger.info("✓ Verbatim mode preserves chunk text and still extracts entities")
finally:
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
clear_config_cache()
@pytest.mark.asyncio
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
"""
@@ -2349,7 +2559,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
)
assert len(result) > 0, "Should have retained content"
print(f"\n=== Retained content with tags ===")
print("\n=== Retained content with tags ===")
# Retrieve the document
doc = await memory.get_document(
@@ -2380,6 +2590,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
def test_retain_mission_injected_into_prompt():
"""Test that retain_mission is injected as a FOCUS section into any extraction mode."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
spec = "Focus on technical decisions and architecture choices only."
@@ -2405,6 +2616,7 @@ def test_retain_mission_injected_into_prompt():
def test_retain_mission_absent_when_not_set():
"""Test that no FOCUS section appears when retain_mission is not set."""
from unittest.mock import MagicMock
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
config = MagicMock()
@@ -2421,6 +2633,7 @@ def test_retain_mission_absent_when_not_set():
def test_retain_mission_config_loaded_from_env():
"""Test that retain_mission is loaded from env and is a configurable field."""
import os
from hindsight_api.config import HindsightConfig, _get_raw_config, clear_config_cache
original = os.getenv("HINDSIGHT_API_RETAIN_MISSION")
@@ -2436,3 +2649,141 @@ def test_retain_mission_config_loaded_from_env():
else:
os.environ["HINDSIGHT_API_RETAIN_MISSION"] = original
clear_config_cache()
def test_strategy_overrides_extraction_mode_for_chunks():
"""
Unit test: a named strategy with retain_extraction_mode=chunks causes
extract_facts_from_contents to skip the LLM and return verbatim chunks.
"""
import asyncio
from hindsight_api.config import _get_raw_config, clear_config_cache
from hindsight_api.config_resolver import apply_strategy
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
from hindsight_api.engine.retain.types import RetainContent
clear_config_cache()
base_config = _get_raw_config()
# Build a config that has a strategy overriding to chunks
strategies = {"fast": {"retain_extraction_mode": "chunks"}}
config_with_strategies = base_config.__class__(
**{**base_config.__dict__, "retain_strategies": strategies}
)
strategy_config = apply_strategy(config_with_strategies, "fast")
assert strategy_config.retain_extraction_mode == "chunks"
contents = [
RetainContent(content="Alice deployed the new API on Monday."),
RetainContent(content="Bob reviewed the pull request."),
]
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
extract_facts_from_contents(
contents=contents,
llm_config=None, # chunks must not call the LLM
agent_name="TestAgent",
config=strategy_config,
)
)
assert len(facts) == 2
assert facts[0].fact_text == contents[0].content
assert facts[1].fact_text == contents[1].content
assert usage.total_tokens == 0
logger.info("✓ strategy with chunks mode: no LLM, verbatim chunks, zero tokens")
def test_retain_request_per_item_strategy_field():
"""
Unit test: MemoryItem accepts a strategy field; items with different strategies
are grouped correctly by per-item strategy.
"""
from hindsight_api.api.http import RetainRequest
request = RetainRequest.model_validate(
{
"items": [
{"content": "Alice joined.", "strategy": "fast"},
{"content": "Bob left.", "strategy": "detailed"},
{"content": "Carol arrived."}, # no strategy — falls back to bank default
],
}
)
assert request.items[0].strategy == "fast"
assert request.items[1].strategy == "detailed"
assert request.items[2].strategy is None
# Simulate grouping logic from api_retain handler
strategy_groups: dict = {}
for item in request.items:
strategy_groups.setdefault(item.strategy, []).append(item.content)
assert set(strategy_groups.keys()) == {"fast", "detailed", None}
assert strategy_groups["fast"] == ["Alice joined."]
assert strategy_groups["detailed"] == ["Bob left."]
assert strategy_groups[None] == ["Carol arrived."]
logger.info("✓ per-item strategy grouping works correctly")
@pytest.mark.asyncio
async def test_named_strategy_applied_end_to_end(memory, request_context):
"""
Integration test: a named strategy stored in bank config is actually applied
during retain_batch_async.
Regression test for the bug where strategy was passed through the HTTP layer
but the extraction mode override was silently ignored, always using the bank
default (e.g. 'concise') instead of the strategy's override (e.g. 'chunks').
"""
from hindsight_api.config_resolver import ConfigResolver
bank_id = f"test_strategy_e2e_{datetime.now(timezone.utc).timestamp()}"
try:
# Seed the bank so the row exists before we write config to it
# (update_bank_config is a plain UPDATE — it silently no-ops on missing rows)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "seed"}],
request_context=request_context,
)
# Now configure the bank with a named strategy that overrides to chunks
await memory._config_resolver.update_bank_config(
bank_id,
{
"retain_extraction_mode": "concise", # bank default
"retain_strategies": {
"chunks": {"retain_extraction_mode": "chunks"},
},
},
request_context,
)
contents = [{"content": "Alice deployed the new API on Monday."}]
# Retain using the named strategy
unit_ids_by_content, usage = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
strategy="chunks",
request_context=request_context,
return_usage=True,
)
# chunks produces exactly one fact per chunk (verbatim) and calls no LLM
assert usage.total_tokens == 0, f"chunks should use zero LLM tokens, got {usage.total_tokens}"
assert len(unit_ids_by_content) == 1
assert len(unit_ids_by_content[0]) == 1, "chunks should produce exactly one fact per content item"
# Verify the stored fact is the verbatim content
facts = await memory.recall_async(bank_id, "Alice", request_context=request_context)
assert any("Alice" in f.text for f in facts.results), "Verbatim content should be retrievable"
logger.info("✓ named strategy 'chunks' with chunks applied end-to-end: no LLM, verbatim storage")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.4.18"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.18"
version = "0.4.19"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+1
View File
@@ -390,6 +390,7 @@ pub fn retain(
entities: None,
tags: None,
observation_scopes: None,
strategy: None,
};
let request = RetainRequest {
+60 -1
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.18
version: 0.4.19
servers:
- url: /
paths:
@@ -2106,6 +2106,46 @@ paths:
summary: Clear all observations
tags:
- Banks
/v1/default/banks/{bank_id}/consolidation/recover:
post:
description: Reset all memories that were permanently marked as failed during
consolidation (after exhausting all LLM retries and adaptive batch splitting)
so they are picked up again on the next consolidation run. Does not delete
any observations.
operationId: recover_consolidation
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/RecoverConsolidationResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Recover failed consolidation
tags:
- Banks
/v1/default/banks/{bank_id}/memories/{memory_id}/observations:
delete:
description: Delete all observations derived from a specific memory and reset
@@ -4014,6 +4054,9 @@ components:
type: array
observation_scopes:
$ref: '#/components/schemas/ObservationScopes'
strategy:
nullable: true
type: string
required:
- content
title: MemoryItem
@@ -4446,6 +4489,17 @@ components:
- id
- text
title: RecallResult
RecoverConsolidationResponse:
description: Response model for recovering failed consolidation.
example:
retried_count: 42
properties:
retried_count:
title: Retried Count
type: integer
required:
- retried_count
title: RecoverConsolidationResponse
ReflectBasedOn:
description: "Evidence the response is based on: memories, mental models, and\
\ directives."
@@ -4795,6 +4849,11 @@ components:
operation_id:
nullable: true
type: string
operation_ids:
items:
type: string
nullable: true
type: array
usage:
$ref: '#/components/schemas/TokenUsage'
required:
+123 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.18
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -1023,6 +1023,128 @@ func (a *BanksAPIService) ListBanksExecute(r ApiListBanksRequest) (*BankListResp
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiRecoverConsolidationRequest struct {
ctx context.Context
ApiService *BanksAPIService
bankId string
authorization *string
}
func (r ApiRecoverConsolidationRequest) Authorization(authorization string) ApiRecoverConsolidationRequest {
r.authorization = &authorization
return r
}
func (r ApiRecoverConsolidationRequest) Execute() (*RecoverConsolidationResponse, *http.Response, error) {
return r.ApiService.RecoverConsolidationExecute(r)
}
/*
RecoverConsolidation Recover failed consolidation
Reset all memories that were permanently marked as failed during consolidation (after exhausting all LLM retries and adaptive batch splitting) so they are picked up again on the next consolidation run. Does not delete any observations.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiRecoverConsolidationRequest
*/
func (a *BanksAPIService) RecoverConsolidation(ctx context.Context, bankId string) ApiRecoverConsolidationRequest {
return ApiRecoverConsolidationRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return RecoverConsolidationResponse
func (a *BanksAPIService) RecoverConsolidationExecute(r ApiRecoverConsolidationRequest) (*RecoverConsolidationResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *RecoverConsolidationResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "BanksAPIService.RecoverConsolidation")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/consolidation/recover"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiResetBankConfigRequest struct {
ctx context.Context
ApiService *BanksAPIService
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
// APIClient manages communication with the Hindsight HTTP API API v0.4.19
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+47 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.18
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -29,6 +29,7 @@ type MemoryItem struct {
Entities []EntityInput `json:"entities,omitempty"`
Tags []string `json:"tags,omitempty"`
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
Strategy NullableString `json:"strategy,omitempty"`
}
type _MemoryItem MemoryItem
@@ -342,6 +343,48 @@ func (o *MemoryItem) UnsetObservationScopes() {
o.ObservationScopes.Unset()
}
// GetStrategy returns the Strategy field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MemoryItem) GetStrategy() string {
if o == nil || IsNil(o.Strategy.Get()) {
var ret string
return ret
}
return *o.Strategy.Get()
}
// GetStrategyOk returns a tuple with the Strategy 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 *MemoryItem) GetStrategyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Strategy.Get(), o.Strategy.IsSet()
}
// HasStrategy returns a boolean if a field has been set.
func (o *MemoryItem) HasStrategy() bool {
if o != nil && o.Strategy.IsSet() {
return true
}
return false
}
// SetStrategy gets a reference to the given NullableString and assigns it to the Strategy field.
func (o *MemoryItem) SetStrategy(v string) {
o.Strategy.Set(&v)
}
// SetStrategyNil sets the value for Strategy to be an explicit nil
func (o *MemoryItem) SetStrategyNil() {
o.Strategy.Set(nil)
}
// UnsetStrategy ensures that no value is present for Strategy, not even an explicit nil
func (o *MemoryItem) UnsetStrategy() {
o.Strategy.Unset()
}
func (o MemoryItem) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -374,6 +417,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) {
if o.ObservationScopes.IsSet() {
toSerialize["observation_scopes"] = o.ObservationScopes.Get()
}
if o.Strategy.IsSet() {
toSerialize["strategy"] = o.Strategy.Get()
}
return toSerialize, nil
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -0,0 +1,158 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the RecoverConsolidationResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &RecoverConsolidationResponse{}
// RecoverConsolidationResponse Response model for recovering failed consolidation.
type RecoverConsolidationResponse struct {
RetriedCount int32 `json:"retried_count"`
}
type _RecoverConsolidationResponse RecoverConsolidationResponse
// NewRecoverConsolidationResponse instantiates a new RecoverConsolidationResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewRecoverConsolidationResponse(retriedCount int32) *RecoverConsolidationResponse {
this := RecoverConsolidationResponse{}
this.RetriedCount = retriedCount
return &this
}
// NewRecoverConsolidationResponseWithDefaults instantiates a new RecoverConsolidationResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewRecoverConsolidationResponseWithDefaults() *RecoverConsolidationResponse {
this := RecoverConsolidationResponse{}
return &this
}
// GetRetriedCount returns the RetriedCount field value
func (o *RecoverConsolidationResponse) GetRetriedCount() int32 {
if o == nil {
var ret int32
return ret
}
return o.RetriedCount
}
// GetRetriedCountOk returns a tuple with the RetriedCount field value
// and a boolean to check if the value has been set.
func (o *RecoverConsolidationResponse) GetRetriedCountOk() (*int32, bool) {
if o == nil {
return nil, false
}
return &o.RetriedCount, true
}
// SetRetriedCount sets field value
func (o *RecoverConsolidationResponse) SetRetriedCount(v int32) {
o.RetriedCount = v
}
func (o RecoverConsolidationResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o RecoverConsolidationResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["retried_count"] = o.RetriedCount
return toSerialize, nil
}
func (o *RecoverConsolidationResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"retried_count",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varRecoverConsolidationResponse := _RecoverConsolidationResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varRecoverConsolidationResponse)
if err != nil {
return err
}
*o = RecoverConsolidationResponse(varRecoverConsolidationResponse)
return err
}
type NullableRecoverConsolidationResponse struct {
value *RecoverConsolidationResponse
isSet bool
}
func (v NullableRecoverConsolidationResponse) Get() *RecoverConsolidationResponse {
return v.value
}
func (v *NullableRecoverConsolidationResponse) Set(val *RecoverConsolidationResponse) {
v.value = val
v.isSet = true
}
func (v NullableRecoverConsolidationResponse) IsSet() bool {
return v.isSet
}
func (v *NullableRecoverConsolidationResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableRecoverConsolidationResponse(val *RecoverConsolidationResponse) *NullableRecoverConsolidationResponse {
return &NullableRecoverConsolidationResponse{value: val, isSet: true}
}
func (v NullableRecoverConsolidationResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableRecoverConsolidationResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.18
API version: 0.4.19
*/
// 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.18
API version: 0.4.19
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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