Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 3f978b446d fix: remove unused PluginState import from tools.ts 2026-04-07 10:05:36 +02:00
Nicolò Boschi a4a9d32480 fix: review fixes for opencode integration
- Rename CI job from build-opencode-integration to test-opencode-integration
  to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files
2026-04-07 09:49:19 +02:00
DK09876andClaude Opus 4.6 8b4c7eeb8e fix: recall retry semantics and README bank scoping clarity
1. recallForContext now returns { context, ok } to distinguish
   "no results" (ok=true) from "API error" (ok=false). System
   transform consumes the session on ok=true even with 0 results,
   so empty banks don't cause repeated queries. Only transient API
   failures preserve retry.

2. README clarifies that channel/user bank dimensions are process-
   scoped (set via env vars before launch), not per-session dynamic
   within a running OpenCode process.

89 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 311b96a192 fix: docs/tools findings from second review round
1. Remove "session" from supported dynamic bank fields in docs —
   the implementation can't vary bank ID per session since it's
   derived once at plugin startup.

2. Explicit tools (retain, reflect) now call ensureBankMission()
   before API calls, so bankMission/retainMission are applied even
   when the agent uses tools exclusively without triggering hooks.

3. Added tests for mission setup via tools path.

88 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 fdc48b1544 fix: address review findings for opencode integration
1. Pre-compaction retain now uses shared retainSession() helper,
   respecting retainMode, documentId, and session_id metadata
   consistently with idle-retain (was bypassing retention policy).

2. System transform recall is only consumed after successful injection.
   If Hindsight is briefly unavailable, the plugin retries on the next
   LLM call instead of permanently skipping recall for the session.

3. Config validation for retainMode and recallBudget — typos like
   "full_session" or "maximum" now log a warning and fall back to
   the default instead of silently changing retention semantics.

85 tests (6 new covering compaction documentId, recall retry, and
config validation).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 ebb05cf9ca feat: add OpenCode persistent memory plugin
Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page

79 tests across 6 test files.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
Nicolò Boschi 66cbdda3cb test: add regression tests for #874 and #894 (#901)
Add tests for None event_date in fact extraction (AttributeError fix)
and for _register_profile skipping .env overwrite with short config keys.
2026-04-07 09:43:25 +02:00
Nicolò Boschi cf4bd598b4 fix: make bank_id metric label opt-in to prevent OTel memory leak (#898)
* fix: make bank_id metric label opt-in to prevent OTel memory leak

bank_id as an OTel metric attribute creates unbounded histogram growth
since each unique bank_id produces never-evicted time series. Default
to excluding it; opt in with HINDSIGHT_API_METRICS_INCLUDE_BANK_ID=true
for deployments with few banks.

Closes #850

* refactor: use config.py for metrics_include_bank_id setting

Move HINDSIGHT_API_METRICS_INCLUDE_BANK_ID from direct os.getenv in
metrics.py to the standard HindsightConfig path. Add configuration
documentation.
2026-04-07 09:42:59 +02:00
Nicolò Boschi 443c94c827 fix(mcp): auto-coerce string-encoded JSON in tool arguments (#849) (#899)
LLM agents frequently serialize list/dict tool arguments as JSON strings
instead of native types (e.g., tags='["a","b"]' instead of tags=["a","b"]),
causing Pydantic validation failures. This extends _make_tools_tolerant to
detect array/object parameters from the JSON Schema and auto-coerce string
values via json.loads before validation.

Also fixes _make_tools_tolerant compatibility with FastMCP 3.x by adding
a _get_mcp_tools helper that supports both 2.x and 3.x internal APIs.
2026-04-07 09:33:12 +02:00
Abdulkadirklc 26794aab09 feat(recall): add proof_count boost to combined scoring (#821)
* feat(recall): add proof_count boost to combined scoring

Observations with more supporting evidence now rank slightly higher
in recall results. proof_count is threaded through the retrieval
pipeline and applied as a multiplicative boost in reranking:

- types.py: add proof_count field to RetrievalResult
- retrieval.py: include proof_count in SELECT columns
- reranking.py: add log1p-normalized proof_count boost (alpha=0.1)

The boost uses the same multiplicative pattern as recency and temporal
signals. proof_count=1 is neutral, proof_count=50 gives ~+5% boost.
Non-observation fact types are unaffected (neutral 0.5).

* fix(retrieval): Apply proof_count boost to graph and temporal retrieval, normalize scaling

* fix(retrieval): correct proof_norm math to zero-center at count 1

* fix(retrieval): Apply proof_count boost to link_expansion retrieval

* fix: remove BFS zombie, clamp proof_norm to [0,1], fix test comment (log1p->math.log)
2026-04-07 09:32:44 +02:00
Nicolò Boschi 7863ffeb49 fix(paperclip): address review fixes for paperclip integration (#900)
- Add CI job for paperclip integration tests with change detection
- Add paperclip to valid release integrations
- Validate hindsightApiUrl is set in loadConfig()
- Log warnings on recall/retain failures instead of silently swallowing
- Remove hardcoded timeout from reflect call
- Fix tsconfig module resolution to Node16
- Update tests to pass required hindsightApiUrl
2026-04-07 09:32:24 +02:00
Octopus 9e2890ba81 fix(embed): skip profile .env overwrite when config has no HINDSIGHT_API_* keys (#896)
When the daemon is already running, ensure_running() calls _register_profile()
with a config dict using short keys (llm_api_key, llm_provider, etc.) that do
not match the HINDSIGHT_API_* prefix filter. This caused api_config to always
be empty, and create_profile() would overwrite the existing .env with an empty
file on every CLI command.

Add an early return guard so _register_profile() skips the create_profile()
call when api_config is empty, preserving any existing profile configuration.

Fixes #894
2026-04-07 09:28:53 +02:00
Chris Bartholomew e0e65c44f6 fix(query_analyzer): handle dateparser internal crashes gracefully (#893)
DateparserQueryAnalyzer.analyze() called dateparser.search.search_dates()
without any error handling, so internal bugs in the third-party library
propagated all the way up the search/consolidation pipeline and failed
the calling task.

Observed traceback:

  File ".../engine/query_analyzer.py", line 140, in analyze
    results = self._search_dates(query, settings=settings)
  File ".../dateparser/search/search.py", line 294, in search_dates
    "Dates": self.search.search_parse(...)
  File ".../dateparser/search/search.py", line 168, in search_parse
    translated, original = self.search(shortname, text, settings)
  File ".../dateparser/languages/locale.py", line 224, in translate_search
    [original_tokens[i], original_tokens[i + 1]],
  IndexError: list index out of range

Wrap the call in a try/except so any parser failure is treated as
"no temporal constraint found" — the caller can then fall back to
non-temporal retrieval instead of erroring out the whole task. The
failure is logged at WARNING level so we still notice it.

Add a regression test that monkey-patches _search_dates to raise an
IndexError and asserts the analyzer returns an empty constraint and
emits a warning log.
2026-04-07 09:26:27 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6881f63781 chore(deps): bump actions/github-script from 7 to 8 (#879)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 09:25:55 +02:00
Daniyar 6cb309f72b Fix AttributeError when event_date is None in fact_extraction (#875)
* Fix AttributeError when event_date is None in fact_extraction

`_extract_facts_from_chunk` crashes with `'NoneType' object has no
attribute 'isoformat'` when retaining documents without a timestamp.

Two locations fixed:
- Line 1058: debug log called `event_date.isoformat()` without a None
  check
- Line 921: `parse_datetime_flexible()` can return None, so re-check
  before calling `.strftime()` / `.isoformat()`

Fixes #874

* Revert unnecessary None guard on line 921

The original `if event_date is not None:` already guards that block.
Only line 1058 needed the fix.
2026-04-07 09:22:07 +02:00
shun yiandyishun.eason f9fe6953a3 fix: Windows compatibility for hindsight-embed (#867)
- Add cross-platform file locking support
- Use fcntl on Unix-like systems, msvcrt on Windows
- Add detailed documentation explaining why we don't use external libraries
- Fixes issue where module couldn't be imported on Windows due to missing fcntl

Co-authored-by: yishun.eason <[email protected]>
2026-04-07 09:15:59 +02:00
Volodymyr Prypeshniuk 07de798c3b feat(google): add support for google embeddings and reranker (#863)
* Add support for google embeddings gemini/vertex and google reranker via vertex search api

* Add reference docs
2026-04-07 09:15:31 +02:00
Byeonghoon YooandClaude Opus 4.6 cefa75545a feat(helm): add persistent volume for local model cache (#861)
* feat(helm): add persistent volume for local model cache

When using local reranker (e.g., BAAI/bge-reranker-v2-m3) or local
embedding models, the models are downloaded to /home/hindsight/.cache
on every pod restart, causing slow startup and unnecessary bandwidth.

Add optional persistent volume support:
- api: PVC mounted at /home/hindsight/.cache
- worker: volumeClaimTemplate (StatefulSet) at same path

Disabled by default. Enable via:
  api.persistence.modelCache.enabled: true
  worker.persistence.modelCache.enabled: true

Closes #860

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(helm): add extraVolumes and extraVolumeMounts for api and worker

Allow users to mount arbitrary volumes (configMaps, secrets, emptyDir,
etc.) into api and worker pods via values, following common helm chart
library conventions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-07 09:14:09 +02:00
Octopus cd99eef4c5 fix: use max_tokens for OpenAI-compatible endpoints with custom base URL (#858)
Mistral (and several other providers) reject 'max_completion_tokens' with a 422
because they haven't adopted the newer OpenAI parameter name. When the openai
provider is configured with a custom base_url (e.g. Mistral, Together AI),
fall back to the widely-supported 'max_tokens' parameter.

Native OpenAI (no custom base_url) and Groq still use 'max_completion_tokens'.

Fixes #852
2026-04-07 09:13:08 +02:00
Ben cd4b3e96e2 blog: Persistent Memory for AutoGen Agents with Hindsight (#883)
* Add AutoGen persistent memory blog post
2026-04-06 14:59:43 -04:00
Ben e02e7ad3d4 blog: Hindsight is now a native memory provider in Hermes Agent (#882)
* Add Hermes native memory provider blog post
2026-04-06 10:55:48 -04:00
Ben 98fee1e380 docs(hermes): update integration docs for plugin overhaul (hermes-agent#5094) (#881)
* docs(hermes): update integration docs for hermes-agent plugin overhaul
2026-04-06 10:54:59 -04:00
Nicolò Boschi 906b740dd7 fix(docs): add missing SEO frontmatter to paperclip integration 2026-04-02 17:37:02 +02:00
Nicolò Boschi 7990381f6a fix(ci): resolve all CI failures (#847)
* fix(ci): resolve all CI failures — unversioned integrations, test retries

- Move integration docs to separate unversioned docs plugin (docs-integrations/)
  so new integrations don't need to be duplicated across versioned_docs
- Remove integration pages from versioned_docs (v0.3, v0.4) — sidebar
  entries now use links instead of doc refs
- Add missing title/description SEO frontmatter to autogen.md
- Add retry logic (2 attempts) to test-doc-examples.sh for transient
  LLM timeouts
- Add pytest-rerunfailures to test-api with --reruns 2 for flaky
  Gemini-dependent integration tests

* ci: retrigger

* fix: graph entity inheritance, SyncTaskBackend error propagation, fact_type test regressions

- Fix observation entity inheritance in get_graph_data: the unit_entities
  query only fetched entities for visible observation IDs, not their source
  memory IDs, so the inheritance loop always found an empty entity_map
- Remove error swallowing in SyncTaskBackend._execute_task so test failures
  surface instead of being silently logged
- Wrap remaining consolidation submission call sites with try/except since
  consolidation is non-critical for those operations
- Fix test_sync_backend test to expect errors to propagate
- Remove fact_type=["world"] filter from test_document_upsert_behavior and
  test_mentioned_at_from_context_string (same PR #848 regression)
- Remove flaky marker from consolidation test (now deterministic)
2026-04-02 17:17:42 +02:00
150 changed files with 11647 additions and 12346 deletions
+73 -3
View File
@@ -45,6 +45,8 @@ jobs:
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
@@ -117,6 +119,10 @@ jobs:
- 'hindsight-integrations/hermes/**'
integrations-llamaindex:
- 'hindsight-integrations/llamaindex/**'
integrations-paperclip:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
dev:
- 'hindsight-dev/**'
ci:
@@ -326,6 +332,37 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
test-opencode-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/opencode
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/opencode
run: npm test
- name: Build
working-directory: ./hindsight-integrations/opencode
run: npm run build
build-chat-integration:
needs: [detect-changes]
if: >-
@@ -357,6 +394,37 @@ jobs:
working-directory: ./hindsight-integrations/chat
run: npm run build
test-paperclip-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-paperclip == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/paperclip
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/paperclip
run: npm run build
- name: Run tests
working-directory: ./hindsight-integrations/paperclip
run: npm test
build-control-plane:
needs: [detect-changes]
if: >-
@@ -2427,7 +2495,9 @@ jobs:
- test-codex-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- build-chat-integration
- test-paperclip-integration
- build-control-plane
- build-docs
- test-rust-cli
@@ -2462,7 +2532,7 @@ jobs:
steps:
- name: Determine overall result
id: result
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const needs = ${{ toJSON(needs) }};
@@ -2495,7 +2565,7 @@ jobs:
core.setOutput('run_url', runUrl);
- name: Report status to PR
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
await github.rest.repos.createCommitStatus({
@@ -2509,7 +2579,7 @@ jobs:
});
- name: Comment on PR
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const prNumber = context.payload.pull_request.number;
@@ -95,6 +95,27 @@ spec:
{{- toYaml .Values.api.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.api.resources | nindent 10 }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumeMounts }}
volumeMounts:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.api.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumes }}
volumes:
{{- if .Values.api.persistence.modelCache.enabled }}
- name: model-cache
persistentVolumeClaim:
claimName: {{ include "hindsight.fullname" . }}-api-model-cache
{{- end }}
{{- with .Values.api.extraVolumes }}
{{- toYaml . | nindent 6 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -0,0 +1,21 @@
{{- if and .Values.api.enabled .Values.api.persistence.modelCache.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "hindsight.fullname" . }}-api-model-cache
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
{{- with .Values.api.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.api.persistence.modelCache.accessModes | nindent 4 }}
{{- if .Values.api.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.api.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.api.persistence.modelCache.size }}
{{- end }}
@@ -95,6 +95,16 @@ spec:
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.worker.resources | nindent 10 }}
{{- if or .Values.worker.persistence.modelCache.enabled .Values.worker.extraVolumeMounts }}
volumeMounts:
{{- if .Values.worker.persistence.modelCache.enabled }}
- name: model-cache
mountPath: /home/hindsight/.cache
{{- end }}
{{- with .Values.worker.extraVolumeMounts }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -107,4 +117,26 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.worker.extraVolumes }}
volumes:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- if .Values.worker.persistence.modelCache.enabled }}
volumeClaimTemplates:
- metadata:
name: model-cache
{{- with .Values.worker.persistence.modelCache.annotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.worker.persistence.modelCache.accessModes | nindent 8 }}
{{- if .Values.worker.persistence.modelCache.storageClass }}
storageClassName: {{ .Values.worker.persistence.modelCache.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.worker.persistence.modelCache.size }}
{{- end }}
{{- end }}
+53
View File
@@ -67,6 +67,33 @@ api:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Models are downloaded to /home/hindsight/.cache on first use.
# Without persistence, models are re-downloaded on every pod restart.
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the api container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the api pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -140,6 +167,32 @@ worker:
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Uses volumeClaimTemplates since worker is a StatefulSet.
persistence:
modelCache:
enabled: false
size: 5Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
# Extra volume mounts for the worker container
# e.g.
# extraVolumeMounts:
# - name: my-volume
# mountPath: /mnt/my-volume
extraVolumeMounts: []
# Extra volumes for the worker pod
# e.g.
# extraVolumes:
# - name: my-volume
# configMap:
# name: my-configmap
extraVolumes: []
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
@@ -1,7 +1,7 @@
"""Fix per-bank vector indexes to match configured extension
Revision ID: a4b5c6d7e8f9
Revises: c2d3e4f5g6h7, c5d6e7f8a9b0
Revises: d6e7f8a9b0c1
Create Date: 2026-04-01
Migration d5e6f7a8b9c0 hardcoded HNSW when creating per-bank partial vector
@@ -21,10 +21,7 @@ from alembic import context, op
from sqlalchemy import text
revision: str = "a4b5c6d7e8f9"
# Updated: the merge migration d6e7f8a9b0c1 was renamed to d6e7f8a9b0c2
# to avoid colliding with the case_insensitive_entities_trgm_index migration
# that shares the same revision ID.
down_revision: str | Sequence[str] | None = "d6e7f8a9b0c2"
down_revision: str | Sequence[str] | None = "d6e7f8a9b0c1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
+99 -5
View File
@@ -156,24 +156,65 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
return mcp
def _get_mcp_tools(mcp: FastMCP) -> dict:
"""Get tool name→object mapping, compatible with FastMCP 2.x and 3.x."""
# FastMCP 2.x: _tool_manager._tools
if hasattr(mcp, "_tool_manager"):
return mcp._tool_manager._tools # type: ignore[union-attr]
# FastMCP 3.x: _local_provider._components with "tool:" prefix
if hasattr(mcp, "_local_provider"):
return {
k.split(":")[1].split("@")[0]: v
for k, v in mcp._local_provider._components.items() # type: ignore[union-attr]
if k.startswith("tool:")
}
msg = "Cannot locate tools on FastMCP instance"
raise AttributeError(msg)
def _make_tools_tolerant(mcp: FastMCP) -> None:
"""Wrap all tool run methods to strip unknown arguments before validation.
"""Wrap all tool run methods to strip unknown arguments and coerce string-encoded JSON.
LLMs frequently add extra fields like "explanation" or "reasoning" to tool calls.
FastMCP's Pydantic TypeAdapter rejects these with "Unexpected keyword argument".
This wraps each tool's run() to filter arguments to only known parameters.
LLMs also frequently serialize list/dict arguments as JSON strings instead of native
types (e.g., tags='["a","b"]' instead of tags=["a","b"]). This auto-coerces them.
This wraps each tool's run() to apply both fixes before validation.
"""
try:
for name, tool in mcp._tool_manager._tools.items(): # type: ignore[unresolved-attribute] # FastMCP 2.x internal; guarded by try/except
tools = _get_mcp_tools(mcp)
for name, tool in tools.items():
if hasattr(tool, "parameters") and tool.parameters:
allowed = set(tool.parameters.get("properties", {}).keys())
properties = tool.parameters.get("properties", {})
allowed = set(properties.keys())
# Build sets of parameter names that expect array or object types.
# Handles both direct types {"type": "array"} and anyOf/oneOf unions
# like {"anyOf": [{"type": "array", ...}, {"type": "null"}]}.
array_params: set[str] = set()
object_params: set[str] = set()
for param_name, param_schema in properties.items():
_collect_coercible_types(param_schema, param_name, array_params, object_params)
original_run = tool.run
async def _tolerant_run(arguments, _allowed=allowed, _orig=original_run):
async def _tolerant_run(
arguments,
_allowed=allowed,
_orig=original_run,
_array_params=array_params,
_object_params=object_params,
):
extra_keys = set(arguments.keys()) - _allowed
if extra_keys:
logger.debug(f"Stripping unknown arguments from tool call: {extra_keys}")
arguments = {k: v for k, v in arguments.items() if k in _allowed}
# Coerce string-encoded JSON for list/dict parameters
arguments = _coerce_string_json(arguments, _array_params, _object_params)
return await _orig(arguments)
# FunctionTool is a Pydantic model with extra='forbid', so use
@@ -183,6 +224,59 @@ def _make_tools_tolerant(mcp: FastMCP) -> None:
logger.warning(f"Could not make tools tolerant of extra arguments: {e}")
def _collect_coercible_types(schema: dict, param_name: str, array_params: set[str], object_params: set[str]) -> None:
"""Check a JSON Schema property and add param_name to array_params/object_params if applicable."""
# Direct type
schema_type = schema.get("type")
if schema_type == "array":
array_params.add(param_name)
return
if schema_type == "object":
object_params.add(param_name)
return
# anyOf / oneOf unions (e.g., list[str] | None → {"anyOf": [{"type": "array"}, {"type": "null"}]})
for variant in schema.get("anyOf", []) + schema.get("oneOf", []):
variant_type = variant.get("type")
if variant_type == "array":
array_params.add(param_name)
return
if variant_type == "object":
object_params.add(param_name)
return
def _coerce_string_json(arguments: dict, array_params: set[str], object_params: set[str]) -> dict:
"""Auto-coerce string-encoded JSON arrays/objects to native types.
LLM agents frequently serialize list and dict tool arguments as JSON strings.
This is backward-compatible: native arrays/objects pass through unchanged.
"""
for param_name in array_params:
val = arguments.get(param_name)
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, list):
arguments = {**arguments, param_name: parsed}
logger.debug(f"Coerced string to list for parameter '{param_name}'")
except (json.JSONDecodeError, TypeError):
pass
for param_name in object_params:
val = arguments.get(param_name)
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, dict):
arguments = {**arguments, param_name: parsed}
logger.debug(f"Coerced string to dict for parameter '{param_name}'")
except (json.JSONDecodeError, TypeError):
pass
return arguments
class MCPMiddleware:
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
@@ -178,6 +178,14 @@ ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
# Gemini/Vertex AI embeddings configuration
ENV_EMBEDDINGS_GEMINI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY"
ENV_EMBEDDINGS_GEMINI_MODEL = "HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL"
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY"
ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID"
ENV_EMBEDDINGS_VERTEXAI_REGION = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION"
ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Cohere configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
@@ -231,6 +239,11 @@ ENV_RERANKER_ZEROENTROPY_API_KEY = "HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY"
ENV_RERANKER_ZEROENTROPY_MODEL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL"
ENV_RERANKER_ZEROENTROPY_BASE_URL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL"
# Google Discovery Engine reranker configuration
ENV_RERANKER_GOOGLE_MODEL = "HINDSIGHT_API_RERANKER_GOOGLE_MODEL"
ENV_RERANKER_GOOGLE_PROJECT_ID = "HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID"
ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY"
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
@@ -256,6 +269,7 @@ ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
@@ -403,6 +417,8 @@ DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_GEMINI_MODEL = "gemini-embedding-001"
DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = 768
DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
@@ -426,6 +442,8 @@ DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, or pgvectorscale)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
@@ -535,6 +553,7 @@ DEFAULT_DISPOSITION_EMPATHY = None
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
# Audit log defaults
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
@@ -706,6 +725,13 @@ class HindsightConfig:
embeddings_litellm_sdk_model: str
embeddings_litellm_sdk_api_base: str | None
embeddings_litellm_sdk_output_dimensions: int | None
# Gemini/Vertex AI embeddings
embeddings_gemini_api_key: str | None
embeddings_gemini_model: str
embeddings_gemini_output_dimensionality: int | None
embeddings_vertexai_project_id: str | None
embeddings_vertexai_region: str | None
embeddings_vertexai_service_account_key: str | None
# Reranker
reranker_provider: str
@@ -733,6 +759,9 @@ class HindsightConfig:
reranker_zeroentropy_api_key: str | None
reranker_zeroentropy_model: str
reranker_zeroentropy_base_url: str | None
reranker_google_model: str
reranker_google_project_id: str | None
reranker_google_service_account_key: str | None
# Server
host: str
@@ -850,6 +879,7 @@ class HindsightConfig:
otel_exporter_otlp_headers: str | None
otel_service_name: str
otel_deployment_environment: str
metrics_include_bank_id: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
@@ -882,6 +912,10 @@ class HindsightConfig:
"reranker_zeroentropy_base_url",
# Service Account Keys
"llm_vertexai_service_account_key",
"embeddings_vertexai_service_account_key",
"reranker_google_service_account_key",
# Embeddings API keys
"embeddings_gemini_api_key",
# File storage credentials
"file_storage_s3_access_key_id",
"file_storage_s3_secret_access_key",
@@ -1160,6 +1194,20 @@ class HindsightConfig:
embeddings_litellm_sdk_output_dimensions=int(v)
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS))
else None,
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),
embeddings_gemini_output_dimensionality=int(
os.getenv(
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY,
str(DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY),
)
),
embeddings_vertexai_project_id=os.getenv(ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID)
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
embeddings_vertexai_region=os.getenv(ENV_EMBEDDINGS_VERTEXAI_REGION) or os.getenv(ENV_LLM_VERTEXAI_REGION),
embeddings_vertexai_service_account_key=os.getenv(ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY)
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
@@ -1209,6 +1257,12 @@ class HindsightConfig:
reranker_zeroentropy_api_key=os.getenv(ENV_RERANKER_ZEROENTROPY_API_KEY),
reranker_zeroentropy_model=os.getenv(ENV_RERANKER_ZEROENTROPY_MODEL, DEFAULT_RERANKER_ZEROENTROPY_MODEL),
reranker_zeroentropy_base_url=os.getenv(ENV_RERANKER_ZEROENTROPY_BASE_URL) or None,
# Google Discovery Engine reranker (with fallback to LLM Vertex AI keys)
reranker_google_model=os.getenv(ENV_RERANKER_GOOGLE_MODEL, DEFAULT_RERANKER_GOOGLE_MODEL),
reranker_google_project_id=os.getenv(ENV_RERANKER_GOOGLE_PROJECT_ID)
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
reranker_google_service_account_key=os.getenv(ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY)
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
@@ -1368,6 +1422,8 @@ class HindsightConfig:
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
in ("true", "1", "yes"),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
@@ -20,6 +20,7 @@ from ..config import (
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_GOOGLE_MODEL,
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
@@ -36,6 +37,7 @@ from ..config import (
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LITELLM_SDK_API_KEY,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
@@ -1266,6 +1268,164 @@ class JinaMLXCrossEncoder(CrossEncoderModel):
return await loop.run_in_executor(None, self._predict_sync, pairs)
class GoogleCrossEncoder(CrossEncoderModel):
"""
Google Discovery Engine cross-encoder using the Ranking REST API.
Uses httpx + google-auth for lightweight REST calls (no gRPC/protobuf).
Supports ADC (Application Default Credentials) or service account key file.
Available models:
- semantic-ranker-default-004: Best quality, 1024 tokens/record (recommended)
- semantic-ranker-fast-004: Lower latency, 1024 tokens/record
Max 200 records per API request. Location is always "global".
"""
MAX_RECORDS_PER_REQUEST = 200
API_BASE = "https://discoveryengine.googleapis.com/v1"
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
def __init__(
self,
project_id: str,
model: str = DEFAULT_RERANKER_GOOGLE_MODEL,
service_account_key: str | None = None,
location: str = "global",
timeout: float = 60.0,
):
"""
Initialize Google Discovery Engine cross-encoder.
Args:
project_id: Google Cloud project ID
model: Ranking model name (default: semantic-ranker-default-004)
service_account_key: Path to service account JSON key file.
If None, uses Application Default Credentials (ADC).
location: API location (default: "global")
timeout: Request timeout in seconds (default: 60.0)
"""
self.project_id = project_id
self.model = model
self.service_account_key = service_account_key
self.location = location
self.timeout = timeout
self._credentials = None
self._client: httpx.Client | None = None
self._rank_url: str | None = None
@property
def provider_name(self) -> str:
return "google"
def _get_auth_headers(self) -> dict[str, str]:
"""Get Authorization header with a fresh access token."""
import google.auth.transport.requests
if not self._credentials.valid:
self._credentials.refresh(google.auth.transport.requests.Request())
return {"Authorization": f"Bearer {self._credentials.token}"}
async def initialize(self) -> None:
"""Initialize credentials and HTTP client."""
if self._client is not None:
return
auth_method = "ADC" if not self.service_account_key else "service_account"
logger.info(
f"Reranker: initializing Google Discovery Engine provider "
f"(project={self.project_id}, model={self.model}, auth={auth_method})"
)
if self.service_account_key:
try:
from google.oauth2 import service_account
except ImportError:
raise ImportError(
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
)
self._credentials = service_account.Credentials.from_service_account_file(
self.service_account_key,
scopes=self.SCOPES,
)
else:
try:
import google.auth
except ImportError:
raise ImportError(
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
)
self._credentials, _ = google.auth.default(scopes=self.SCOPES)
ranking_config = f"projects/{self.project_id}/locations/{self.location}/rankingConfigs/default_ranking_config"
self._rank_url = f"{self.API_BASE}/{ranking_config}:rank"
self._client = httpx.Client(timeout=self.timeout)
logger.info("Reranker: Google Discovery Engine provider initialized")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict via REST API."""
if not pairs:
return []
# Group pairs by query
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
# Process in batches of MAX_RECORDS_PER_REQUEST
for batch_start in range(0, len(texts), self.MAX_RECORDS_PER_REQUEST):
batch_texts = texts[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
batch_indices = indices[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
records = [{"id": str(i), "content": text} for i, text in enumerate(batch_texts)]
response = self._client.post(
self._rank_url,
headers=self._get_auth_headers(),
json={
"model": self.model,
"query": query,
"records": records,
"topN": len(records),
},
)
response.raise_for_status()
result = response.json()
for record in result.get("records", []):
local_idx = int(record["id"])
all_scores[batch_indices[local_idx]] = record["score"]
return all_scores
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using Google Discovery Engine Ranking API.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores (0-1, higher = more relevant)
"""
if self._client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync, pairs)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
@@ -1341,11 +1501,23 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
api_key=api_key,
model=config.reranker_zeroentropy_model,
)
elif provider == "google":
project_id = config.reranker_google_project_id
if not project_id:
raise ValueError(
f"{ENV_RERANKER_GOOGLE_PROJECT_ID} (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
f"is required when {ENV_RERANKER_PROVIDER} is 'google'"
)
return GoogleCrossEncoder(
project_id=project_id,
model=config.reranker_google_model,
service_account_key=config.reranker_google_service_account_key,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
elif provider == "jina-mlx":
return JinaMLXCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
@@ -19,6 +19,7 @@ import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
@@ -28,6 +29,7 @@ from ..config import (
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
@@ -884,6 +886,179 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
Supports both:
1. Gemini API (api.generativeai.google.com) with API key authentication
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
"""
def __init__(
self,
model: str = DEFAULT_EMBEDDINGS_GEMINI_MODEL,
api_key: str | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_service_account_key: str | None = None,
output_dimensionality: int | None = None,
batch_size: int = 100,
):
self.model = model
self.api_key = api_key
self.vertexai_project_id = vertexai_project_id
self.vertexai_region = vertexai_region or "us-central1"
self.vertexai_service_account_key = vertexai_service_account_key
self.output_dimensionality = output_dimensionality
self.batch_size = batch_size
self._client = None
self._dimension: int | None = None
self._is_vertexai = vertexai_project_id is not None
self._embed_config = None # EmbedContentConfig, built during initialize()
@property
def provider_name(self) -> str:
return "google"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Initialize the Google genai client and detect embedding dimension."""
if self._client is not None:
return
from google import genai
from google.genai import types as genai_types
if self._is_vertexai:
self._init_vertexai(genai)
else:
self._init_gemini(genai)
# Build EmbedContentConfig if output_dimensionality is set
if self.output_dimensionality is not None:
self._embed_config = genai_types.EmbedContentConfig(
output_dimensionality=self.output_dimensionality,
)
# Detect dimension via a test embedding (respects output_dimensionality)
embed_kwargs = {"model": self.model, "contents": ["test"]}
if self._embed_config is not None:
embed_kwargs["config"] = self._embed_config
result = self._client.models.embed_content(**embed_kwargs) # type: ignore[union-attr]
if result.embeddings and len(result.embeddings) > 0:
self._dimension = len(result.embeddings[0].values)
auth_mode = "vertex_ai" if self._is_vertexai else "api_key"
logger.info(
f"Embeddings: google provider initialized (auth: {auth_mode}, model: {self.model}, dim: {self._dimension})"
)
def _init_gemini(self, genai) -> None:
"""Initialize Gemini API client with API key."""
if not self.api_key:
raise ValueError("Gemini embeddings provider requires an API key")
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Embeddings: initializing Gemini provider with model {self.model}")
def _init_vertexai(self, genai) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
if not self.vertexai_project_id:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
"is required for Vertex AI embeddings provider."
)
auth_method = "ADC"
credentials = None
if self.vertexai_service_account_key:
try:
from google.oauth2 import service_account
except ImportError:
raise ImportError(
"Vertex AI service account auth requires 'google-auth' package. "
"Install with: pip install google-auth"
)
credentials = service_account.Credentials.from_service_account_file(
self.vertexai_service_account_key,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
auth_method = "service_account"
logger.info(f"Embeddings: Vertex AI using service account key: {self.vertexai_service_account_key}")
# Strip google/ prefix from model name — native SDK uses bare names
if self.model.startswith("google/"):
self.model = self.model[len("google/") :]
client_kwargs = {
"vertexai": True,
"project": self.vertexai_project_id,
"location": self.vertexai_region,
}
if credentials is not None:
client_kwargs["credentials"] = credentials
self._client = genai.Client(**client_kwargs)
logger.info(
f"Embeddings: initializing Vertex AI provider "
f"(project={self.vertexai_project_id}, region={self.vertexai_region}, "
f"model={self.model}, auth={auth_method})"
)
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the Google genai SDK.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
embed_kwargs["config"] = self._embed_config
result = self._client.models.embed_content(**embed_kwargs)
all_embeddings.extend([emb.values for emb in result.embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
# for accurate cosine similarity.
if self.output_dimensionality is not None:
import numpy as np
arr = np.array(all_embeddings)
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1
all_embeddings = (arr / norms).tolist()
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on configuration.
@@ -947,8 +1122,27 @@ def create_embeddings_from_env() -> Embeddings:
api_base=config.embeddings_litellm_sdk_api_base,
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
)
elif provider == "google":
vertexai_project_id = config.embeddings_vertexai_project_id
if vertexai_project_id:
api_key = None # Vertex AI uses ADC or service account
else:
api_key = config.embeddings_gemini_api_key
if not api_key:
raise ValueError(
f"{ENV_EMBEDDINGS_GEMINI_API_KEY} or {ENV_LLM_API_KEY} is required "
f"when {ENV_EMBEDDINGS_PROVIDER} is 'google' (set VERTEXAI_PROJECT_ID for Vertex AI auth instead)"
)
return GeminiEmbeddings(
model=config.embeddings_gemini_model,
api_key=api_key,
vertexai_project_id=vertexai_project_id,
vertexai_region=config.embeddings_vertexai_region,
vertexai_service_account_key=config.embeddings_vertexai_service_account_key,
output_dimensionality=config.embeddings_gemini_output_dimensionality,
)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
f"Supported: 'local', 'tei', 'openai', 'cohere', 'google', 'litellm', 'litellm-sdk'"
)
@@ -3625,7 +3625,10 @@ class MemoryEngine(MemoryEngineInterface):
}
if invalidated_obs > 0:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
try:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
except Exception as e:
logger.warning(f"Failed to submit consolidation after document deletion for bank {bank_id}: {e}")
return result
@@ -3759,7 +3762,10 @@ class MemoryEngine(MemoryEngineInterface):
)
if invalidated_obs > 0:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
try:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
except Exception as e:
logger.warning(f"Failed to submit consolidation after document update for bank {bank_id}: {e}")
return True
@@ -3821,7 +3827,14 @@ class MemoryEngine(MemoryEngineInterface):
}
if bank_id_for_consolidation:
await self.submit_async_consolidation(bank_id=bank_id_for_consolidation, request_context=request_context)
try:
await self.submit_async_consolidation(
bank_id=bank_id_for_consolidation, request_context=request_context
)
except Exception as e:
logger.warning(
f"Failed to submit consolidation after memory deletion for bank {bank_id_for_consolidation}: {e}"
)
return result
@@ -3830,6 +3843,7 @@ class MemoryEngine(MemoryEngineInterface):
bank_id: str,
fact_type: str | None = None,
*,
delete_bank_profile: bool = True,
request_context: "RequestContext",
) -> dict[str, int]:
"""
@@ -3916,20 +3930,21 @@ class MemoryEngine(MemoryEngineInterface):
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
await conn.execute(f"DELETE FROM {fq_table('entities')} WHERE bank_id = $1", bank_id)
# Delete the bank profile and retrieve internal_id for HNSW index cleanup
internal_id = await conn.fetchval(
f"DELETE FROM {fq_table('banks')} WHERE bank_id = $1 RETURNING internal_id", bank_id
)
if internal_id:
bank_internal_id = str(internal_id)
result = {
"memory_units_deleted": units_count,
"entities_deleted": entities_count,
"documents_deleted": documents_count,
"bank_deleted": True,
}
if delete_bank_profile:
# Delete the bank profile and retrieve internal_id for HNSW index cleanup
internal_id = await conn.fetchval(
f"DELETE FROM {fq_table('banks')} WHERE bank_id = $1 RETURNING internal_id", bank_id
)
if internal_id:
bank_internal_id = str(internal_id)
result["bank_deleted"] = True
except Exception as e:
raise Exception(f"Failed to delete agent data: {str(e)}")
@@ -3940,7 +3955,10 @@ class MemoryEngine(MemoryEngineInterface):
await bank_utils.drop_bank_vector_indexes(conn, bank_internal_id)
if invalidated_obs > 0:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
try:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
except Exception as e:
logger.warning(f"Failed to submit consolidation after bank deletion for bank {bank_id}: {e}")
return result
@@ -4331,7 +4349,10 @@ class MemoryEngine(MemoryEngineInterface):
]
# Get entity information — only for visible units
if unit_ids:
# Fetch entities for visible units AND their source memories
# (so observations can inherit entities from source memories)
entity_lookup_ids = unit_ids + source_memory_ids
if entity_lookup_ids:
unit_entities = await conn.fetch(
f"""
SELECT ue.unit_id, e.canonical_name
@@ -4340,7 +4361,7 @@ class MemoryEngine(MemoryEngineInterface):
WHERE ue.unit_id = ANY($1::uuid[])
ORDER BY ue.unit_id
""",
unit_ids,
entity_lookup_ids,
)
else:
unit_entities = []
@@ -6340,6 +6361,7 @@ class MemoryEngine(MemoryEngineInterface):
*,
tags: list[str] | None = None,
tags_match: str = "any",
detail: str = "full",
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -6350,6 +6372,7 @@ class MemoryEngine(MemoryEngineInterface):
bank_id: Bank identifier
tags: Optional tags to filter by
tags_match: How to match tags - 'any', 'all', or 'exact'
detail: Detail level - 'metadata', 'content', or 'full'
limit: Maximum number of results
offset: Offset for pagination
request_context: Request context for authentication
@@ -6391,13 +6414,14 @@ class MemoryEngine(MemoryEngineInterface):
*params,
)
return [self._row_to_mental_model(row) for row in rows]
return [self._row_to_mental_model(row, detail=detail) for row in rows]
async def get_mental_model(
self,
bank_id: str,
mental_model_id: str,
*,
detail: str = "full",
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""Get a single pinned mental model by ID.
@@ -6405,6 +6429,7 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: Bank identifier
mental_model_id: Pinned mental model UUID
detail: Detail level - 'metadata', 'content', or 'full'
request_context: Request context for authentication
Returns:
@@ -6438,7 +6463,7 @@ class MemoryEngine(MemoryEngineInterface):
mental_model_id,
)
result = self._row_to_mental_model(row) if row else None
result = self._row_to_mental_model(row, detail=detail) if row else None
# Post-operation hook (usage recording)
if result and self._operation_validator:
@@ -6836,34 +6861,45 @@ class MemoryEngine(MemoryEngineInterface):
return result == "DELETE 1"
def _row_to_mental_model(self, row) -> dict[str, Any]:
"""Convert a database row to a mental model dict."""
reflect_response = row.get("reflect_response")
# Parse JSON string to dict if needed (asyncpg may return JSONB as string)
if isinstance(reflect_response, str):
try:
reflect_response = json.loads(reflect_response)
except json.JSONDecodeError:
reflect_response = None
def _row_to_mental_model(self, row, *, detail: str = "full") -> dict[str, Any]:
"""Convert a database row to a mental model dict.
Args:
row: Database row
detail: Detail level - 'metadata', 'content', or 'full'
"""
result: dict[str, Any] = {
"id": str(row["id"]),
"bank_id": row["bank_id"],
"name": row["name"],
"tags": row["tags"] or [],
"last_refreshed_at": row["last_refreshed_at"].isoformat() if row["last_refreshed_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
if detail == "metadata":
return result
trigger = row.get("trigger")
if isinstance(trigger, str):
try:
trigger = json.loads(trigger)
except json.JSONDecodeError:
trigger = None
return {
"id": str(row["id"]),
"bank_id": row["bank_id"],
"name": row["name"],
"source_query": row["source_query"],
"content": row["content"],
"tags": row["tags"] or [],
"max_tokens": row.get("max_tokens"),
"trigger": trigger,
"last_refreshed_at": row["last_refreshed_at"].isoformat() if row["last_refreshed_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"reflect_response": reflect_response,
}
result["source_query"] = row["source_query"]
result["content"] = row["content"]
result["max_tokens"] = row.get("max_tokens")
result["trigger"] = trigger
if detail == "full":
reflect_response = row.get("reflect_response")
if isinstance(reflect_response, str):
try:
reflect_response = json.loads(reflect_response)
except json.JSONDecodeError:
reflect_response = None
result["reflect_response"] = reflect_response
return result
# =========================================================================
# Directives - Hard rules injected into prompts
@@ -191,6 +191,23 @@ class OpenAICompatibleLLM(LLMInterface):
return None
def _max_tokens_param_name(self) -> str:
"""Return the correct parameter name for limiting response tokens.
Native OpenAI and Groq accept 'max_completion_tokens'. Mistral and other
OpenAI-compatible endpoints that haven't adopted the newer parameter name
require 'max_tokens'. Using a custom base_url with the openai provider
signals a third-party compatible API, so fall back to 'max_tokens'.
"""
# Native OpenAI (no custom base URL) and Groq use max_completion_tokens
if self.provider == "groq":
return "max_completion_tokens"
if self.provider == "openai" and not self.base_url:
return "max_completion_tokens"
# openai with custom base_url, ollama, lmstudio, minimax, volcano —
# use the widely-supported max_tokens
return "max_tokens"
async def call(
self,
messages: list[dict[str, str]],
@@ -263,9 +280,7 @@ class OpenAICompatibleLLM(LLMInterface):
# For reasoning models, enforce minimum to ensure space for reasoning + output
if is_reasoning_model and max_completion_tokens < 16000:
max_completion_tokens = 16000
call_params["max_completion_tokens"] = max_completion_tokens
# Temperature - reasoning models don't support custom temperature
call_params[self._max_tokens_param_name()] = max_completion_tokens
if temperature is not None and not is_reasoning_model:
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
if self.provider == "minimax":
@@ -577,7 +592,7 @@ class OpenAICompatibleLLM(LLMInterface):
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
call_params[self._max_tokens_param_name()] = max_completion_tokens
if temperature is not None:
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
if self.provider == "minimax":
@@ -137,7 +137,21 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
"RETURN_AS_TIMEZONE_AWARE": False,
}
results = self._search_dates(query, settings=settings)
# Wrap dateparser in a defensive try/except. dateparser has been
# observed to crash with internal errors (e.g., IndexError from
# locale.translate_search) on certain query inputs. A parser bug
# should not bring down the whole search/consolidation pipeline —
# treat any failure as "no temporal constraint found" so the caller
# can fall back to non-temporal retrieval.
try:
results = self._search_dates(query, settings=settings)
except Exception as e:
logger.warning(
"dateparser raised %s on query (treating as no temporal constraint): %s",
type(e).__name__,
e,
)
return QueryAnalysis(temporal_constraint=None)
if not results:
return QueryAnalysis(temporal_constraint=None)
@@ -1055,7 +1055,7 @@ async def _extract_facts_from_chunk(
f"LLM response missing 'facts' field or returned empty list. "
f"Response: {extraction_response_json}. "
f"Input: "
f"date: {event_date.isoformat()}, "
f"date: {event_date.isoformat() if event_date else 'unset'}, "
f"context: {context if context else 'none'}, "
f"text: {chunk}"
)
@@ -59,7 +59,7 @@ async def _find_semantic_seeds(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags,
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
@@ -274,7 +274,7 @@ class LinkExpansionRetriever(GraphRetriever):
-- Score = COUNT(DISTINCT shared entities), mapped to [0,1] via tanh.
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
COUNT(DISTINCT ue_seed.entity_id)::float AS score,
'entity'::text AS source
FROM {ue} ue_seed
@@ -298,14 +298,14 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
fact_type, document_id, chunk_id, tags, proof_count,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.to_unit_id
@@ -317,7 +317,7 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.from_unit_id
@@ -328,7 +328,7 @@ class LinkExpansionRetriever(GraphRetriever):
) sem_raw
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags
fact_type, document_id, chunk_id, tags, proof_count
ORDER BY score DESC
LIMIT $3
),
@@ -339,7 +339,7 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
ml.weight AS score,
'causal'::text AS source
FROM {ml} ml
@@ -429,7 +429,7 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
(SELECT COUNT(DISTINCT s) FROM unnest(mu.source_memory_ids) s WHERE s = ANY(ca.source_ids))::float AS score
FROM {fq_table("memory_units")} mu, connected_array ca
WHERE mu.fact_type = 'observation'
@@ -453,13 +453,13 @@ class LinkExpansionRetriever(GraphRetriever):
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
fact_type, document_id, chunk_id, tags, proof_count,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
@@ -467,21 +467,21 @@ class LinkExpansionRetriever(GraphRetriever):
UNION ALL
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
) sem_raw
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count
ORDER BY score DESC LIMIT $2
),
causal_expanded AS (
SELECT DISTINCT ON (mu.id)
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
mu.chunk_id, mu.tags, ml.weight AS score, 'causal'::text AS source
mu.chunk_id, mu.tags, mu.proof_count, ml.weight AS score, 'causal'::text AS source
FROM {ml} ml JOIN {mu} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
@@ -2,6 +2,7 @@
Cross-encoder neural reranking for search results.
"""
import math
from datetime import datetime, timezone
from .types import MergedCandidate, ScoredResult
@@ -13,6 +14,7 @@ UTC = timezone.utc
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
_RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
_PROOF_COUNT_ALPHA: float = 0.1 # Conservative: max ±5% for evidence strength
def apply_combined_scoring(
@@ -20,28 +22,40 @@ def apply_combined_scoring(
now: datetime,
recency_alpha: float = _RECENCY_ALPHA,
temporal_alpha: float = _TEMPORAL_ALPHA,
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
Uses the cross-encoder score as the primary relevance signal, with recency
and temporal proximity applied as multiplicative boosts. This ensures the
influence of these secondary signals is always proportional to the base
relevance score, regardless of the cross-encoder model's score calibration.
Uses the cross-encoder score as the primary relevance signal, with recency,
temporal proximity, and proof count applied as multiplicative boosts. This
ensures the influence of these secondary signals is always proportional to
the base relevance score, regardless of the cross-encoder model's score
calibration.
Formula::
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
proof_count_boost = 1 + proof_count_alpha * (proof_norm - 0.5) # in [1-α/2, 1+α/2]
combined_score = CE_normalized * recency_boost * temporal_boost * proof_count_boost
proof_norm maps proof_count using a smooth logarithmic curve centered at 0.5,
clamped to [0, 1]:
proof_count=1 → 0.5 + 0 = 0.5 (neutral multiplier)
proof_count=150 → clamped to 1.0 (max +5% boost)
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
so temporal_boost collapses to 1.0 for non-temporal queries.
Proof count is treated as neutral (0.5) when not available (non-observation facts),
so proof_count_boost collapses to 1.0 for world/experience/opinion facts.
Args:
scored_results: Results from the cross-encoder reranker. Mutated in place.
now: Current UTC datetime for recency calculation.
recency_alpha: Max relative recency adjustment (default 0.2 → ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 → ±10%).
proof_count_alpha: Max relative proof count adjustment (default 0.1 → ±5%).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
@@ -59,13 +73,23 @@ def apply_combined_scoring(
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
# Proof count: log-normalized evidence strength; neutral for non-observations.
proof_count = sr.retrieval.proof_count
if proof_count is not None and proof_count >= 1:
# Clamp to [0, 1] so extreme counts stay within documented ±5% range
proof_norm = min(1.0, max(0.0, 0.5 + (math.log(proof_count) / 10.0)))
else:
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
proof_norm = 0.5
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
sr.rrf_normalized = 0.0
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
proof_count_boost = 1.0 + proof_count_alpha * (proof_norm - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost * proof_count_boost
sr.weight = sr.combined_score
@@ -141,7 +141,7 @@ async def retrieve_semantic_bm25_combined(
cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
"fact_type, document_id, chunk_id, tags, metadata"
"fact_type, document_id, chunk_id, tags, metadata, proof_count"
)
table = fq_table("memory_units")
@@ -336,7 +336,7 @@ async def retrieve_temporal_combined(
{groups_clause}
),
sim_ranked AS (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.proof_count, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
1 - (mu.embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
FROM date_ranked dr
@@ -344,7 +344,7 @@ async def retrieve_temporal_combined(
WHERE dr.rn <= 50
AND (1 - (mu.embedding <=> $1::vector)) >= $6
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, metadata, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, proof_count, document_id, chunk_id, tags, metadata, similarity
FROM sim_ranked
WHERE sim_rn <= 10
""",
@@ -48,6 +48,7 @@ class RetrievalResult:
chunk_id: str | None = None
tags: list[str] | None = None # Visibility scope tags
metadata: dict[str, str] | None = None # User-provided metadata
proof_count: int | None = None # Number of supporting memories (observations only)
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: float | None = None # Semantic retrieval
@@ -72,6 +73,7 @@ class RetrievalResult:
chunk_id=row.get("chunk_id"),
tags=row.get("tags"),
metadata=row.get("metadata"),
proof_count=row.get("proof_count"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
@@ -82,20 +82,16 @@ class TaskBackend(ABC):
Args:
task_dict: Task dictionary to execute
Raises:
Exception: Re-raised from executor on failure.
"""
if self._executor is None:
task_type = task_dict.get("type", "unknown")
logger.warning(f"No executor registered, skipping task {task_type}")
return
try:
await self._executor(task_dict)
except Exception as e:
task_type = task_dict.get("type", "unknown")
logger.error(f"Error executing task {task_type}: {e}")
import traceback
traceback.print_exc()
await self._executor(task_dict)
class SyncTaskBackend(TaskBackend):
+5 -1
View File
@@ -252,6 +252,9 @@ class MetricsCollector(MetricsCollectorBase):
def __init__(self):
self.meter = get_meter()
from .config import get_config
self._include_bank_id = get_config().metrics_include_bank_id
# Operation latency histogram (in seconds)
# Records duration of retain, recall, reflect operations
@@ -332,10 +335,11 @@ class MetricsCollector(MetricsCollectorBase):
start_time = time.time()
attributes = {
"operation": operation,
"bank_id": bank_id,
"source": source,
"tenant": _get_tenant(),
}
if self._include_bank_id:
attributes["bank_id"] = bank_id
if budget:
attributes["budget"] = budget
if max_tokens:
+1
View File
@@ -136,6 +136,7 @@ dev = [
"pytest-asyncio>=1.3.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pytest-rerunfailures>=15.0",
"python-dotenv>=1.2.1",
"filelock>=3.20.1", # TOCTOU race condition fix
"ruff>=0.8.0",
@@ -139,3 +139,76 @@ async def test_retain_llm_max_retries_overrides_global():
assert facts == []
# Verify it retried exactly retain_llm_max_retries times
assert llm_config.call.call_count == 5
@pytest.mark.asyncio
async def test_none_event_date_with_empty_facts_no_crash():
"""
When event_date is None and the LLM returns an empty facts list,
the debug log should not crash with AttributeError on .isoformat().
Regression test for https://github.com/vectorize-io/hindsight/issues/874
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
config = _make_config(llm_max_retries=1)
# LLM returns a valid dict but with no facts — triggers the debug log path
llm_config = _make_llm_config(mock_response={"facts": []})
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, usage = await _extract_facts_from_chunk(
chunk="A plain text document with no timestamp.",
chunk_index=0,
total_chunks=1,
event_date=None,
context="",
llm_config=llm_config,
config=config,
agent_name="test-agent",
)
assert facts == []
@pytest.mark.asyncio
async def test_none_event_date_with_valid_facts_no_crash():
"""
When event_date is None but the LLM returns valid facts,
extraction should succeed without errors.
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
config = _make_config(llm_max_retries=1)
llm_config = _make_llm_config(mock_response={
"facts": [
{
"what": "Alice visited Paris",
"when": "2023",
"who": "Alice",
"why": "vacation",
}
]
})
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, usage = await _extract_facts_from_chunk(
chunk="Alice visited Paris in 2023.",
chunk_index=0,
total_chunks=1,
event_date=None,
context="",
llm_config=llm_config,
config=config,
agent_name="test-agent",
)
assert len(facts) == 1
assert "Alice visited Paris" in facts[0].fact
@@ -0,0 +1,336 @@
"""
Tests for Google embeddings implementation (Gemini API + Vertex AI).
These tests cover:
1. Initialization (Gemini API key, Vertex AI with ADC/service account)
2. Dimension detection via test embedding
3. Output dimensionality configuration
4. Encode (single text, multiple texts, batching, empty list, uninitialized)
5. Provider name and model name normalization
6. Factory function (create from env, validation errors)
"""
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.config import (
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_PROVIDER,
HindsightConfig,
)
from hindsight_api.engine.embeddings import GeminiEmbeddings, create_embeddings_from_env
def _make_mock_embedding(values: list[float]) -> MagicMock:
emb = MagicMock()
emb.values = values
return emb
def _make_mock_embed_result(embeddings_data: list[list[float]]) -> MagicMock:
result = MagicMock()
result.embeddings = [_make_mock_embedding(v) for v in embeddings_data]
return result
def _make_mock_genai(embed_result: Any = None) -> MagicMock:
if embed_result is None:
embed_result = _make_mock_embed_result([[0.1] * 768])
mock_genai = MagicMock()
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=embed_result)
mock_genai.Client = MagicMock(return_value=mock_client)
return mock_genai
def _make_mock_google_module(mock_genai: MagicMock) -> MagicMock:
mod = MagicMock()
mod.genai = mock_genai
mod.genai.types.EmbedContentConfig = MagicMock(side_effect=lambda **kw: MagicMock(**kw))
return mod
def _patch_google_import(mock_genai: MagicMock):
original_import = __import__
def mock_import(name, *args, **kwargs):
if name == "google":
return _make_mock_google_module(mock_genai)
if name == "google.genai":
return mock_genai
return original_import(name, *args, **kwargs)
return patch("builtins.__import__", side_effect=mock_import)
class TestGeminiEmbeddings:
"""Unit tests for GeminiEmbeddings with mocked google.genai."""
async def test_initialization_api_key_success(self):
"""Test successful Gemini API key initialization."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._client is not None
assert emb.dimension == 768
assert emb.provider_name == "google"
assert emb._is_vertexai is False
mock_genai.Client.return_value.models.embed_content.assert_called_once()
async def test_initialization_vertexai_success(self):
"""Test successful Vertex AI initialization."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(
model="gemini-embedding-001",
vertexai_project_id="test-project",
vertexai_region="us-central1",
)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._client is not None
assert emb.dimension == 768
assert emb.provider_name == "google"
assert emb._is_vertexai is True
mock_genai.Client.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
)
async def test_initialization_missing_api_key(self):
"""Test that missing API key raises ValueError when no vertexai_project_id."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key=None)
with _patch_google_import(mock_genai):
with pytest.raises(ValueError, match="requires an API key"):
await emb.initialize()
async def test_initialization_vertexai_missing_project_id(self):
"""Test that Vertex AI mode requires project_id."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", vertexai_project_id="temp")
emb.vertexai_project_id = None # Simulate misconfiguration
with _patch_google_import(mock_genai):
with pytest.raises(ValueError, match="is required for Vertex AI"):
await emb.initialize()
async def test_initialization_idempotent(self):
"""Test that calling initialize() twice is a no-op."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
first_client = emb._client
await emb.initialize()
assert emb._client is first_client
async def test_dimension_detection_via_test_embedding(self):
"""Test that dimension is detected via a test embedding call."""
test_embed = _make_mock_embed_result([[0.5] * 256])
mock_genai = _make_mock_genai(embed_result=test_embed)
emb = GeminiEmbeddings(model="some-new-model", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb.dimension == 256
async def test_output_dimensionality(self):
"""Test that output_dimensionality is passed via EmbedContentConfig."""
test_embed = _make_mock_embed_result([[0.1] * 256])
mock_genai = _make_mock_genai(embed_result=test_embed)
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=256)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb.dimension == 256
assert emb._embed_config is not None
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
assert "config" in call_kwargs.kwargs
async def test_no_output_dimensionality(self):
"""Test that no EmbedContentConfig is built when output_dimensionality is None."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=None)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._embed_config is None
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
assert "config" not in call_kwargs.kwargs
def test_auto_detect_vertexai(self):
"""Test that _is_vertexai is auto-detected from vertexai_project_id."""
assert GeminiEmbeddings(model="m", api_key="k")._is_vertexai is False
assert GeminiEmbeddings(model="m", vertexai_project_id="p")._is_vertexai is True
def test_encode_single_text(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2, 0.3]]))
emb._client = mock_client
emb._dimension = 3
assert emb.encode(["hello"]) == [[0.1, 0.2, 0.3]]
def test_encode_multiple_texts(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(
return_value=_make_mock_embed_result([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
)
emb._client = mock_client
emb._dimension = 2
result = emb.encode(["a", "b", "c"])
assert len(result) == 3
assert result[1] == [0.3, 0.4]
def test_encode_batching(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", batch_size=2)
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(
side_effect=[_make_mock_embed_result([[0.1], [0.2]]), _make_mock_embed_result([[0.3]])]
)
emb._client = mock_client
emb._dimension = 1
assert emb.encode(["a", "b", "c"]) == [[0.1], [0.2], [0.3]]
assert mock_client.models.embed_content.call_count == 2
def test_encode_passes_config(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2]]))
emb._client = mock_client
emb._dimension = 2
emb._embed_config = MagicMock()
emb.encode(["hello"])
assert mock_client.models.embed_content.call_args.kwargs["config"] is emb._embed_config
def test_encode_empty_list(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
emb._client = MagicMock()
emb._dimension = 768
assert emb.encode([]) == []
def test_encode_before_initialization(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with pytest.raises(RuntimeError, match="not initialized"):
emb.encode(["test"])
def test_dimension_before_initialization(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with pytest.raises(RuntimeError, match="not initialized"):
_ = emb.dimension
def test_provider_name_always_google(self):
assert GeminiEmbeddings(model="m", api_key="k").provider_name == "google"
assert GeminiEmbeddings(model="m", vertexai_project_id="p").provider_name == "google"
def test_vertexai_strips_google_prefix(self):
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="google/gemini-embedding-001", vertexai_project_id="test-project")
emb._init_vertexai(mock_genai)
assert emb.model == "gemini-embedding-001"
def test_default_region(self):
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj")
assert emb.vertexai_region == "us-central1"
def test_custom_region(self):
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj", vertexai_region="europe-west1")
assert emb.vertexai_region == "europe-west1"
class TestGeminiEmbeddingsFactory:
"""Tests for create_embeddings_from_env() with 'google' provider."""
def _make_config(self, **overrides) -> HindsightConfig:
from dataclasses import fields
defaults = {}
for f in fields(HindsightConfig):
if f.type == "str":
defaults[f.name] = ""
elif f.type == "str | None":
defaults[f.name] = None
elif f.type == "int":
defaults[f.name] = 0
elif f.type == "int | None":
defaults[f.name] = None
elif f.type == "float":
defaults[f.name] = 0.0
elif f.type == "float | None":
defaults[f.name] = None
elif f.type == "bool":
defaults[f.name] = False
elif f.type == "list | None":
defaults[f.name] = None
else:
defaults[f.name] = None
defaults["embeddings_provider"] = "google"
defaults["embeddings_gemini_api_key"] = "test-key"
defaults["embeddings_gemini_model"] = "gemini-embedding-001"
defaults["embeddings_gemini_output_dimensionality"] = 768
defaults["embeddings_vertexai_project_id"] = None
defaults["embeddings_vertexai_region"] = None
defaults["embeddings_vertexai_service_account_key"] = None
defaults.update(overrides)
return HindsightConfig(**defaults)
def test_create_with_api_key(self):
config = self._make_config()
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert isinstance(emb, GeminiEmbeddings)
assert emb.provider_name == "google"
assert emb.api_key == "test-key"
assert emb._is_vertexai is False
def test_create_with_vertexai(self):
config = self._make_config(
embeddings_gemini_api_key=None,
embeddings_vertexai_project_id="my-project",
embeddings_vertexai_region="us-east1",
)
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert isinstance(emb, GeminiEmbeddings)
assert emb._is_vertexai is True
assert emb.api_key is None
assert emb.vertexai_project_id == "my-project"
def test_create_missing_all_credentials(self):
config = self._make_config(embeddings_gemini_api_key=None, embeddings_vertexai_project_id=None)
with patch("hindsight_api.config.get_config", return_value=config):
with pytest.raises(ValueError, match="is required"):
create_embeddings_from_env()
def test_vertexai_takes_priority(self):
config = self._make_config(embeddings_gemini_api_key="key", embeddings_vertexai_project_id="proj")
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert emb._is_vertexai is True
assert emb.api_key is None
def test_create_with_custom_dimensionality(self):
config = self._make_config(embeddings_gemini_output_dimensionality=256)
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert emb.output_dimensionality == 256
@@ -0,0 +1,275 @@
"""
Tests for Google Discovery Engine cross-encoder (Ranking REST API).
These tests cover:
1. Initialization (service account, ADC, missing project_id)
2. Predict (single query, multiple queries, batching, empty pairs, uninitialized)
3. Provider name
4. Factory function (create from env, validation errors)
"""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from hindsight_api.config import (
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_PROVIDER,
HindsightConfig,
)
from hindsight_api.engine.cross_encoder import GoogleCrossEncoder, create_cross_encoder_from_env
def _make_rank_response(records: list[tuple[str, float]]) -> dict:
"""Build a JSON response matching the Discovery Engine REST API format."""
return {"records": [{"id": rid, "score": score} for rid, score in records]}
def _make_mock_httpx_client(responses: list[dict] | None = None) -> MagicMock:
"""Create a mock httpx.Client that returns predefined responses."""
mock_client = MagicMock(spec=httpx.Client)
if responses:
side_effects = []
for resp_json in responses:
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.json.return_value = resp_json
mock_resp.raise_for_status.return_value = None
side_effects.append(mock_resp)
mock_client.post.side_effect = side_effects
return mock_client
def _make_mock_credentials() -> MagicMock:
"""Create mock credentials with a valid token."""
creds = MagicMock()
creds.valid = True
creds.token = "mock-token"
return creds
class TestGoogleCrossEncoder:
"""Unit tests for GoogleCrossEncoder with mocked httpx + google-auth."""
async def test_initialization_adc_success(self):
"""Test successful initialization with ADC (no service account key)."""
mock_creds = _make_mock_credentials()
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "test-project")):
await encoder.initialize()
assert encoder._client is not None
assert encoder._credentials is mock_creds
assert encoder.provider_name == "google"
assert "test-project" in encoder._rank_url
async def test_initialization_service_account(self):
"""Test initialization with service account key."""
mock_creds = _make_mock_credentials()
encoder = GoogleCrossEncoder(
project_id="test-project",
service_account_key="/path/to/key.json",
)
with patch(
"google.oauth2.service_account.Credentials.from_service_account_file",
return_value=mock_creds,
):
await encoder.initialize()
assert encoder._client is not None
assert encoder._credentials is mock_creds
async def test_initialization_idempotent(self):
"""Test that calling initialize() twice is a no-op."""
mock_creds = _make_mock_credentials()
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "test-project")):
await encoder.initialize()
first_client = encoder._client
await encoder.initialize()
assert encoder._client is first_client
async def test_predict_single_query(self):
"""Test prediction with a single query and multiple documents."""
mock_creds = _make_mock_credentials()
mock_client = _make_mock_httpx_client([
_make_rank_response([("1", 0.95), ("0", 0.30)]),
])
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "p")):
await encoder.initialize()
encoder._client = mock_client
scores = await encoder.predict([
("What is AI?", "AI is artificial intelligence"),
("What is AI?", "The sky is blue"),
])
assert len(scores) == 2
assert scores[0] == 0.30 # id="0" -> index 0
assert scores[1] == 0.95 # id="1" -> index 1
mock_client.post.assert_called_once()
async def test_predict_multiple_queries(self):
"""Test prediction with multiple distinct queries."""
mock_creds = _make_mock_credentials()
mock_client = _make_mock_httpx_client([
_make_rank_response([("0", 0.9), ("1", 0.1)]),
_make_rank_response([("0", 0.8)]),
])
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "p")):
await encoder.initialize()
encoder._client = mock_client
scores = await encoder.predict([
("Query A", "Doc A1"),
("Query A", "Doc A2"),
("Query B", "Doc B1"),
])
assert len(scores) == 3
assert scores[0] == 0.9
assert scores[1] == 0.1
assert scores[2] == 0.8
assert mock_client.post.call_count == 2
async def test_predict_empty_pairs(self):
"""Test that empty pairs returns empty list."""
mock_creds = _make_mock_credentials()
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "p")):
await encoder.initialize()
scores = await encoder.predict([])
assert scores == []
async def test_predict_not_initialized(self):
"""Test that predict raises if not initialized."""
encoder = GoogleCrossEncoder(project_id="test-project")
with pytest.raises(RuntimeError, match="not initialized"):
await encoder.predict([("q", "d")])
async def test_predict_batching(self):
"""Test that >200 records are split into batches."""
mock_creds = _make_mock_credentials()
mock_client = _make_mock_httpx_client([
_make_rank_response([(str(i), 0.5) for i in range(200)]),
_make_rank_response([(str(i), 0.3) for i in range(50)]),
])
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "p")):
await encoder.initialize()
encoder._client = mock_client
pairs = [("same query", f"doc {i}") for i in range(250)]
scores = await encoder.predict(pairs)
assert len(scores) == 250
assert mock_client.post.call_count == 2
async def test_auth_header_sent(self):
"""Test that Authorization header is sent with requests."""
mock_creds = _make_mock_credentials()
mock_creds.token = "test-bearer-token"
mock_client = _make_mock_httpx_client([
_make_rank_response([("0", 0.9)]),
])
encoder = GoogleCrossEncoder(project_id="test-project")
with patch("google.auth.default", return_value=(mock_creds, "p")):
await encoder.initialize()
encoder._client = mock_client
await encoder.predict([("q", "d")])
call_kwargs = mock_client.post.call_args
assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer test-bearer-token"
def test_provider_name(self):
assert GoogleCrossEncoder(project_id="p").provider_name == "google"
def test_default_model(self):
encoder = GoogleCrossEncoder(project_id="p")
assert encoder.model == "semantic-ranker-default-004"
def test_custom_model(self):
encoder = GoogleCrossEncoder(project_id="p", model="semantic-ranker-fast-004")
assert encoder.model == "semantic-ranker-fast-004"
def test_default_location(self):
encoder = GoogleCrossEncoder(project_id="p")
assert encoder.location == "global"
class TestGoogleCrossEncoderFactory:
"""Tests for create_cross_encoder_from_env() with 'google' provider."""
def _make_config(self, **overrides) -> HindsightConfig:
from dataclasses import fields
defaults = {}
for f in fields(HindsightConfig):
if f.type == "str":
defaults[f.name] = ""
elif f.type == "str | None":
defaults[f.name] = None
elif f.type == "int":
defaults[f.name] = 0
elif f.type == "int | None":
defaults[f.name] = None
elif f.type == "float":
defaults[f.name] = 0.0
elif f.type == "float | None":
defaults[f.name] = None
elif f.type == "bool":
defaults[f.name] = False
elif f.type == "list | None":
defaults[f.name] = None
else:
defaults[f.name] = None
defaults["reranker_provider"] = "google"
defaults["reranker_google_model"] = "semantic-ranker-default-004"
defaults["reranker_google_project_id"] = "test-project"
defaults["reranker_google_service_account_key"] = None
defaults.update(overrides)
return HindsightConfig(**defaults)
def test_create_with_project_id(self):
config = self._make_config()
with patch("hindsight_api.config.get_config", return_value=config):
encoder = create_cross_encoder_from_env()
assert isinstance(encoder, GoogleCrossEncoder)
assert encoder.provider_name == "google"
assert encoder.project_id == "test-project"
assert encoder.service_account_key is None
def test_create_with_service_account(self):
config = self._make_config(reranker_google_service_account_key="/path/to/key.json")
with patch("hindsight_api.config.get_config", return_value=config):
encoder = create_cross_encoder_from_env()
assert isinstance(encoder, GoogleCrossEncoder)
assert encoder.service_account_key == "/path/to/key.json"
def test_create_missing_project_id(self):
config = self._make_config(reranker_google_project_id=None)
with patch("hindsight_api.config.get_config", return_value=config):
with pytest.raises(ValueError, match="is required"):
create_cross_encoder_from_env()
def test_create_with_custom_model(self):
config = self._make_config(reranker_google_model="semantic-ranker-fast-004")
with patch("hindsight_api.config.get_config", return_value=config):
encoder = create_cross_encoder_from_env()
assert encoder.model == "semantic-ranker-fast-004"
@@ -161,6 +161,7 @@ def _parse_history(hist: Any) -> list[str]:
@pytest.mark.asyncio
@pytest.mark.flaky(reruns=2, reruns_delay=5)
async def test_horse_farm_observation_history(memory: MemoryEngine, request_context: Any) -> None:
"""Retain a sequence of horse facts and inspect how observations evolve."""
bank_id = f"test-horses-{uuid.uuid4().hex[:8]}"
@@ -0,0 +1,330 @@
"""Tests for MCP tool argument string-to-JSON coercion (issue #849)."""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api.api.mcp import (
_coerce_string_json,
_collect_coercible_types,
_get_mcp_tools,
_make_tools_tolerant,
)
# ---------------------------------------------------------------------------
# _collect_coercible_types — schema type detection
# ---------------------------------------------------------------------------
class TestCollectCoercibleTypes:
"""Tests for _collect_coercible_types schema detection."""
def _run(self, schema: dict, param_name: str = "p") -> tuple[set[str], set[str]]:
array_params: set[str] = set()
object_params: set[str] = set()
_collect_coercible_types(schema, param_name, array_params, object_params)
return array_params, object_params
# --- array types ---
def test_direct_array_type(self):
arrays, objects = self._run({"type": "array", "items": {"type": "string"}})
assert "p" in arrays and not objects
def test_anyof_nullable_array(self):
"""list[str] | None → anyOf with array and null."""
arrays, objects = self._run(
{"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
)
assert "p" in arrays
def test_oneof_nullable_array(self):
"""oneOf variant."""
arrays, objects = self._run(
{"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
)
assert "p" in arrays
# --- object types ---
def test_direct_object_type(self):
arrays, objects = self._run({"type": "object"})
assert "p" in objects and not arrays
def test_anyof_nullable_object(self):
"""dict[str, str] | None → anyOf with object and null."""
arrays, objects = self._run({"anyOf": [{"type": "object"}, {"type": "null"}]})
assert "p" in objects
def test_oneof_nullable_object(self):
arrays, objects = self._run({"oneOf": [{"type": "object"}, {"type": "null"}]})
assert "p" in objects
# --- non-coercible types (should be ignored) ---
def test_string_type_ignored(self):
arrays, objects = self._run({"type": "string"})
assert not arrays and not objects
def test_integer_type_ignored(self):
arrays, objects = self._run({"type": "integer"})
assert not arrays and not objects
def test_number_type_ignored(self):
arrays, objects = self._run({"type": "number"})
assert not arrays and not objects
def test_boolean_type_ignored(self):
arrays, objects = self._run({"type": "boolean"})
assert not arrays and not objects
def test_null_type_ignored(self):
arrays, objects = self._run({"type": "null"})
assert not arrays and not objects
def test_anyof_string_or_null_ignored(self):
"""str | None should not be collected."""
arrays, objects = self._run({"anyOf": [{"type": "string"}, {"type": "null"}]})
assert not arrays and not objects
def test_anyof_integer_or_null_ignored(self):
arrays, objects = self._run({"anyOf": [{"type": "integer"}, {"type": "null"}]})
assert not arrays and not objects
# ---------------------------------------------------------------------------
# _coerce_string_json — value coercion
# ---------------------------------------------------------------------------
class TestCoerceStringJson:
"""Tests for _coerce_string_json argument coercion."""
# --- list coercion ---
def test_coerce_string_to_list(self):
result = _coerce_string_json(
{"tags": '["tag1", "tag2"]', "query": "hello"},
array_params={"tags"},
object_params=set(),
)
assert result["tags"] == ["tag1", "tag2"]
assert result["query"] == "hello"
def test_coerce_empty_list_string(self):
result = _coerce_string_json({"tags": "[]"}, array_params={"tags"}, object_params=set())
assert result["tags"] == []
def test_native_list_passthrough(self):
result = _coerce_string_json({"tags": ["a", "b"]}, array_params={"tags"}, object_params=set())
assert result["tags"] == ["a", "b"]
# --- dict coercion ---
def test_coerce_string_to_dict(self):
result = _coerce_string_json(
{"metadata": '{"key": "value"}'},
array_params=set(),
object_params={"metadata"},
)
assert result["metadata"] == {"key": "value"}
def test_coerce_empty_dict_string(self):
result = _coerce_string_json({"metadata": "{}"}, array_params=set(), object_params={"metadata"})
assert result["metadata"] == {}
def test_native_dict_passthrough(self):
result = _coerce_string_json(
{"metadata": {"key": "value"}}, array_params=set(), object_params={"metadata"}
)
assert result["metadata"] == {"key": "value"}
# --- non-coercible values left untouched ---
def test_none_passthrough(self):
result = _coerce_string_json({"tags": None}, array_params={"tags"}, object_params=set())
assert result["tags"] is None
def test_invalid_json_string_passthrough(self):
result = _coerce_string_json({"tags": "not-json"}, array_params={"tags"}, object_params=set())
assert result["tags"] == "not-json"
def test_wrong_json_type_not_coerced_list(self):
"""String that parses to a dict should NOT be coerced for an array param."""
result = _coerce_string_json(
{"tags": '{"key": "value"}'}, array_params={"tags"}, object_params=set()
)
assert result["tags"] == '{"key": "value"}'
def test_wrong_json_type_not_coerced_dict(self):
"""String that parses to a list should NOT be coerced for an object param."""
result = _coerce_string_json(
{"metadata": '["a", "b"]'}, array_params=set(), object_params={"metadata"}
)
assert result["metadata"] == '["a", "b"]'
def test_string_param_not_touched(self):
"""Strings not in array_params/object_params are never modified."""
result = _coerce_string_json(
{"query": '["looks", "like", "json"]'},
array_params=set(),
object_params=set(),
)
assert result["query"] == '["looks", "like", "json"]'
def test_integer_param_not_touched(self):
result = _coerce_string_json(
{"max_tokens": 4096}, array_params=set(), object_params=set()
)
assert result["max_tokens"] == 4096
def test_boolean_param_not_touched(self):
result = _coerce_string_json(
{"verbose": True}, array_params=set(), object_params=set()
)
assert result["verbose"] is True
def test_missing_param_no_error(self):
result = _coerce_string_json(
{"query": "hello"},
array_params={"tags"},
object_params={"metadata"},
)
assert result == {"query": "hello"}
# --- multiple params coerced at once ---
def test_multiple_params_coerced(self):
result = _coerce_string_json(
{
"tags": '["a", "b"]',
"types": '["world"]',
"metadata": '{"source": "test"}',
"query": "hello",
"max_tokens": 4096,
},
array_params={"tags", "types"},
object_params={"metadata"},
)
assert result["tags"] == ["a", "b"]
assert result["types"] == ["world"]
assert result["metadata"] == {"source": "test"}
assert result["query"] == "hello"
assert result["max_tokens"] == 4096
# ---------------------------------------------------------------------------
# _make_tools_tolerant — integration test with a real FastMCP tool
# ---------------------------------------------------------------------------
class TestMakeToolsTolerantIntegration:
"""Test that _make_tools_tolerant correctly wraps real FastMCP tool functions."""
def _create_mcp_with_tool(self):
"""Create a FastMCP instance with a tool that uses various parameter types."""
from fastmcp import FastMCP
mcp = FastMCP("test")
captured = {}
@mcp.tool(description="test tool with diverse param types")
async def test_tool(
query: str,
max_tokens: int = 100,
verbose: bool = False,
tags: list[str] | None = None,
metadata: dict[str, str] | None = None,
) -> dict:
"""Test tool.
Args:
query: a string param
max_tokens: an integer param
verbose: a boolean param
tags: an array param
metadata: an object param
"""
captured["query"] = query
captured["max_tokens"] = max_tokens
captured["verbose"] = verbose
captured["tags"] = tags
captured["metadata"] = metadata
return {"ok": True}
return mcp, captured
@pytest.mark.asyncio
async def test_coerces_string_encoded_list(self):
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({"query": "hi", "tags": '["a", "b"]'})
assert captured["tags"] == ["a", "b"]
@pytest.mark.asyncio
async def test_coerces_string_encoded_dict(self):
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({"query": "hi", "metadata": '{"k": "v"}'})
assert captured["metadata"] == {"k": "v"}
@pytest.mark.asyncio
async def test_native_types_pass_through(self):
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({
"query": "hi",
"max_tokens": 200,
"verbose": True,
"tags": ["x"],
"metadata": {"a": "b"},
})
assert captured["query"] == "hi"
assert captured["max_tokens"] == 200
assert captured["verbose"] is True
assert captured["tags"] == ["x"]
assert captured["metadata"] == {"a": "b"}
@pytest.mark.asyncio
async def test_strips_extra_args_and_coerces(self):
"""Both extra-arg stripping and coercion work together."""
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({
"query": "hi",
"tags": '["x"]',
"explanation": "LLM added this",
})
assert captured["tags"] == ["x"]
assert "explanation" not in captured
@pytest.mark.asyncio
async def test_string_param_not_coerced(self):
"""A string param whose value happens to look like JSON is NOT coerced."""
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({"query": '["this", "is", "a", "string"]'})
assert captured["query"] == '["this", "is", "a", "string"]'
@pytest.mark.asyncio
async def test_integer_param_not_coerced(self):
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({"query": "hi", "max_tokens": 50})
assert captured["max_tokens"] == 50
@pytest.mark.asyncio
async def test_boolean_param_not_coerced(self):
mcp, captured = self._create_mcp_with_tool()
_make_tools_tolerant(mcp)
tool = _get_mcp_tools(mcp)["test_tool"]
await tool.run({"query": "hi", "verbose": True})
assert captured["verbose"] is True
+24 -3
View File
@@ -76,7 +76,10 @@ class TestMetricsCollector:
@pytest.fixture
def collector(self, mock_meter):
"""Create a MetricsCollector with a mock meter."""
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
mock_config = MagicMock()
mock_config.metrics_include_bank_id = False
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter), \
patch("hindsight_api.config.get_config", return_value=mock_config):
return MetricsCollector()
def test_record_operation_records_duration(self, collector):
@@ -95,7 +98,7 @@ class TestMetricsCollector:
# Second arg is attributes dict
attributes = call_args[0][1]
assert attributes["operation"] == "recall"
assert attributes["bank_id"] == "test_bank"
assert "bank_id" not in attributes # excluded by default to avoid high-cardinality OTel growth
assert attributes["source"] == "api"
assert attributes["success"] == "true"
@@ -166,6 +169,21 @@ class TestMetricsCollector:
assert reflect_attrs["operation"] == "reflect"
assert reflect_attrs["source"] == "api"
def test_record_operation_includes_bank_id_when_enabled(self):
"""Test that bank_id is included in attributes when metrics_include_bank_id is enabled."""
mock_config = MagicMock()
mock_config.metrics_include_bank_id = True
with patch("hindsight_api.metrics.get_meter") as mock_get_meter, \
patch("hindsight_api.config.get_config", return_value=mock_config):
mock_get_meter.return_value = MagicMock()
collector = MetricsCollector()
with collector.record_operation("recall", bank_id="test_bank", source="api"):
pass
attributes = collector.operation_duration.record.call_args[0][1]
assert attributes["bank_id"] == "test_bank"
class TestGetMetricsCollector:
"""Tests for the get_metrics_collector function."""
@@ -269,7 +287,10 @@ class TestLLMMetrics:
@pytest.fixture
def collector(self, mock_meter):
"""Create a MetricsCollector with a mock meter."""
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
mock_config = MagicMock()
mock_config.metrics_include_bank_id = False
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter), \
patch("hindsight_api.config.get_config", return_value=mock_config):
return MetricsCollector()
def test_record_llm_call_records_duration(self, collector):
@@ -283,3 +283,37 @@ def test_query_analyzer_couple_weeks_ago(query_analyzer):
assert analysis.temporal_constraint.end_date.month == 1 # Jan 8 (1 week before Jan 15)
def test_query_analyzer_dateparser_crash_returns_no_constraint(query_analyzer, monkeypatch, caplog):
"""
dateparser has been observed to crash with internal errors (e.g.,
IndexError from locale.translate_search) on certain query inputs.
A parser bug should not propagate up the search/consolidation pipeline —
the analyzer should treat any failure as "no temporal constraint found".
"""
import logging
reference_date = datetime(2025, 1, 15, 12, 0, 0)
# Make sure the lazy loader has run so we can monkey-patch the cached call.
query_analyzer.load()
def boom(*args, **kwargs):
raise IndexError("list index out of range")
monkeypatch.setattr(query_analyzer, "_search_dates", boom)
# Use a query that doesn't match any of the period regex patterns so the
# code path actually reaches the dateparser call.
query = "tell me what happened recently with the project"
with caplog.at_level(logging.WARNING):
analysis = query_analyzer.analyze(query, reference_date)
assert analysis.temporal_constraint is None, (
"dateparser failures should be treated as no temporal constraint, not propagated"
)
assert any("dateparser" in rec.message for rec in caplog.records), (
"Should log a warning when dateparser fails"
)
@@ -0,0 +1,96 @@
"""
Unit tests for proof_count boost in reranking.
"""
from datetime import datetime, timezone
import pytest
from uuid import uuid4
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult
from hindsight_api.engine.search.reranking import apply_combined_scoring
UTC = timezone.utc
def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 0.8) -> ScoredResult:
"""Helper to create a minimal ScoredResult suitable for scoring tests."""
retrieval = RetrievalResult(
id=uuid4(),
text="Test mock fact",
fact_type="observation" if proof_count is not None else "world",
document_id=uuid4(),
chunk_id=uuid4(),
embedding=[0.1]*384,
similarity=0.9,
proof_count=proof_count,
# Default neutral dates for testing so only proof_count changes score
occurred_start=datetime.now(UTC),
occurred_end=datetime.now(UTC)
)
candidate = MergedCandidate(
id=retrieval.id,
retrieval=retrieval,
semantic_rank=1,
bm25_rank=1,
rrf_score=0.1
)
return ScoredResult(
candidate=candidate,
cross_encoder_score=ce_score,
cross_encoder_score_normalized=ce_score,
weight=ce_score,
)
def test_proof_count_neutral_when_none():
"""Test that when proof_count is None (e.g. non-observation), it gets neutral 0.5 norm."""
sr = create_mock_scored_result(proof_count=None, ce_score=0.8)
now = datetime.now(UTC)
apply_combined_scoring([sr], now, proof_count_alpha=0.1)
# Neutral multiplier means score shouldn't be boosted by proof_count
# Since recency is neutral (just created) and temporal is neutral, score should remain unchanged
assert sr.combined_score == pytest.approx(0.8, rel=1e-3)
def test_proof_count_neutral_at_one():
"""Test that proof_count=1 gives neutral multiplier."""
sr = create_mock_scored_result(proof_count=1, ce_score=0.8)
now = datetime.now(UTC)
apply_combined_scoring([sr], now, proof_count_alpha=0.1)
# proof_count=1 -> math.log(1) = 0 -> 0.5 + 0/10 = 0.5 (neutral) -> multiplier 1.0
assert sr.combined_score == pytest.approx(0.8, rel=1e-3)
def test_proof_count_increases_with_higher_counts():
"""Test that higher proof counts yield strictly higher scores."""
now = datetime.now(UTC)
# Create results with increasing proof counts
sr_5 = create_mock_scored_result(proof_count=5, ce_score=0.8)
sr_50 = create_mock_scored_result(proof_count=50, ce_score=0.8)
sr_100 = create_mock_scored_result(proof_count=100, ce_score=0.8)
# Process them
apply_combined_scoring([sr_5, sr_50, sr_100], now, proof_count_alpha=0.1)
# Assure scores strictly increase
assert sr_5.combined_score > 0.8
assert sr_50.combined_score > sr_5.combined_score
assert sr_100.combined_score > sr_50.combined_score
def test_proof_count_no_hardcoded_cap_at_100():
"""Test that proof_count continues to scale within the clamped [0, 1] range."""
now = datetime.now(UTC)
# Use values that stay below the clamp ceiling (proof_norm < 1.0)
# log(5)/10=0.16, log(20)/10=0.30, log(100)/10=0.46 → all below 0.5 headroom
sr_5 = create_mock_scored_result(proof_count=5, ce_score=0.8)
sr_20 = create_mock_scored_result(proof_count=20, ce_score=0.8)
sr_100 = create_mock_scored_result(proof_count=100, ce_score=0.8)
apply_combined_scoring([sr_5, sr_20, sr_100], now, proof_count_alpha=0.1)
# Must strictly increase within the valid range
assert sr_20.combined_score > sr_5.combined_score
assert sr_100.combined_score > sr_20.combined_score
+13 -14
View File
@@ -245,13 +245,12 @@ async def test_event_date_storage(memory, request_context):
assert len(unit_ids) > 0, "Should have created at least one memory unit"
# Recall the fact
# Recall the fact (no fact_type filter — LLM may classify as world or experience)
result = await memory.recall_async(
bank_id=bank_id,
query="When did Alice complete the product launch?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
)
@@ -547,13 +546,12 @@ async def test_mentioned_at_from_context_string(memory, request_context):
assert len(unit_ids) > 0, "Should create memory unit"
# Recall and verify mentioned_at is set
# Recall and verify mentioned_at is set (no fact_type filter — LLM may classify as world or experience)
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice like?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
)
@@ -738,13 +736,12 @@ async def test_context_preservation(memory, request_context):
assert len(unit_ids) > 0, "Should create at least one memory unit"
# Recall and verify context is returned
# Recall and verify context is returned (no fact_type filter — LLM may classify as world or experience)
result = await memory.recall_async(
bank_id=bank_id,
query="What did the team decide?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
)
@@ -1106,13 +1103,12 @@ async def test_document_upsert_behavior(memory, request_context):
assert len(v2_units) > 0, "Should create units for v2"
# Recall should return the updated information
# Recall should return the updated information (no fact_type filter — LLM may classify as world or experience)
result = await memory.recall_async(
bank_id=bank_id,
query="What is the project status?",
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
)
@@ -2060,20 +2056,23 @@ async def test_semantic_links_phase1_ann_cross_batch(memory, request_context):
bank_id = f"test_semantic_phase1_{datetime.now(timezone.utc).timestamp()}"
try:
# First batch: store some facts about Python
# First batch: store some world facts about a topic
# Use clearly "world" content (general knowledge, not personal experience)
# to ensure consistent fact_type classification across batches,
# since ANN search filters by fact_type.
await memory.retain_async(
bank_id=bank_id,
content="Alice is an expert Python developer who builds web applications using FastAPI.",
context="team skills",
content="Python is a high-level programming language widely used for web development with frameworks like FastAPI.",
context="programming languages",
request_context=request_context,
)
# Second batch: store similar facts — Phase 1 ANN should find the first batch's
# Second batch: store similar world facts — Phase 1 ANN should find the first batch's
# facts via HNSW index and create cross-batch semantic links
unit_ids_2 = await memory.retain_async(
bank_id=bank_id,
content="Bob specializes in Python programming and creates REST APIs with FastAPI.",
context="team skills",
content="FastAPI is a modern Python web framework known for its high performance and automatic API documentation.",
context="programming languages",
request_context=request_context,
)
+5 -4
View File
@@ -952,8 +952,8 @@ class TestSyncTaskBackend:
assert executed[0] == task_dict
@pytest.mark.asyncio
async def test_sync_backend_handles_errors(self):
"""Test that SyncTaskBackend handles executor errors gracefully."""
async def test_sync_backend_propagates_errors(self):
"""Test that SyncTaskBackend propagates executor errors instead of swallowing them."""
async def failing_executor(task_dict):
raise ValueError("Test error")
@@ -962,8 +962,9 @@ class TestSyncTaskBackend:
backend.set_executor(failing_executor)
await backend.initialize()
# Should not raise, error is logged
await backend.submit_task({"type": "test"})
# Should raise so callers can handle or surface the failure
with pytest.raises(ValueError, match="Test error"):
await backend.submit_task({"type": "test"})
class TestDynamicTenantDiscovery:
@@ -8,6 +8,10 @@ image: /img/blog/hermes-agent-memory.png
![How to Add Persistent Memory to Hermes Agent](/img/blog/hermes-agent-memory.png)
:::warning Deprecated
The `hindsight-hermes` pip plugin described in this post is deprecated. Hermes now ships with a native Hindsight memory provider. See [Hindsight Is Now a Native Memory Provider in Hermes Agent](/blog/2026/04/06/hermes-native-memory-provider) for the current setup guide.
:::
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a self-improving AI agent with 40+ tools and a plugin system. Its built-in memory saves to local files. `hindsight-hermes` replaces it with structured fact extraction, entity resolution, and multi-strategy retrieval — via one pip install and three environment variables.
<!-- truncate -->
@@ -0,0 +1,199 @@
---
title: "Persistent Memory for AutoGen Agents with Hindsight"
authors: [DK09876]
date: 2026-04-06
tags: [autogen, integrations, agents, memory, python, microsoft]
description: "AutoGen agents lose all state when a session ends. hindsight-autogen adds three tools — retain, recall, reflect — that give your agents persistent memory across sessions."
image: /img/blog/autogen-persistent-memory.png
hide_table_of_contents: true
---
![Persistent Memory for AutoGen Agents with Hindsight](/img/blog/autogen-persistent-memory.png)
AutoGen is Microsoft's open-source framework for building multi-agent systems: conversable agents, group chats, tool use, code execution. But when a session ends, every agent in the conversation forgets everything. `hindsight-autogen` fixes that by giving AutoGen agents persistent memory through three callable tools.
<!-- truncate -->
## TL;DR
- AutoGen agents have no built-in cross-session memory; state resets every run
- `hindsight-autogen` provides three `FunctionTool` instances for `AssistantAgent`: `hindsight_retain`, `hindsight_recall`, `hindsight_reflect`
- One pip install, pass `tools=[...]` to your agent, done
- Works with [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) or self-hosted
## The problem
AutoGen gives you `AssistantAgent` with chat history within a session. That's a message list; it doesn't extract facts, doesn't build knowledge over time, and disappears when the process exits.
For agents that serve repeat users or run across multiple sessions, you need more:
- A coding assistant that remembers your stack, preferences, and past decisions
- A multi-agent team where a coordinator retains knowledge from previous group chats
- A support agent that knows your account history across dozens of conversations
None of this works with in-session chat history. You need a system that extracts facts from conversations, builds knowledge over time, and retrieves relevant context semantically.
That's what Hindsight does. And `hindsight-autogen` wires it into AutoGen's tool system.
## Architecture
```
AutoGen AssistantAgent(tools=[...])
└─ Hindsight FunctionTools (via create_hindsight_tools)
├─ hindsight_retain → Hindsight retain
│ (fact extraction, entity resolution, knowledge graph)
├─ hindsight_recall → Hindsight recall
│ (semantic + BM25 + graph + temporal retrieval)
└─ hindsight_reflect → Hindsight reflect
(synthesize a reasoned answer from all memories)
```
The tools are `FunctionTool` instances from `autogen_core.tools`, passed directly to `AssistantAgent(tools=[...])`. No subclassing, no custom agent types, just standard AutoGen tool use.
Under the hood, Hindsight extracts structured facts, identifies entities, builds a knowledge graph, and runs four parallel retrieval strategies with cross-encoder reranking.
## Step 1: Start Hindsight
```bash
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=YOUR_OPENAI_KEY
hindsight-api
```
Runs locally at `http://localhost:8888` with embedded Postgres, embeddings, and reranking.
Or use [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) and skip self-hosting.
## Step 2: Install the integration
```bash
pip install hindsight-autogen autogen-agentchat "autogen-ext[openai]"
```
`hindsight-autogen` pulls in `autogen-core` and `hindsight-client`. You also need `autogen-agentchat` for `AssistantAgent` and `autogen-ext[openai]` for the model client.
## Step 3: Create the bank and agent
Banks must exist before use. AutoGen agents are async, so wrap everything in `asyncio.run()`:
```python
import asyncio
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from hindsight_client import Hindsight
from hindsight_autogen import create_hindsight_tools
async def main():
client = Hindsight(base_url="http://localhost:8888")
await client.acreate_bank("user-123", name="User 123 Memory")
model_client = OpenAIChatCompletionClient(model="gpt-4o-mini")
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
tags=["source:chat"],
budget="mid",
)
agent = AssistantAgent(
name="assistant",
model_client=model_client,
tools=tools,
reflect_on_tool_use=True,
system_message=(
"You are a helpful assistant with long-term memory. "
"Use hindsight_retain to store important facts the user shares. "
"Use hindsight_recall to search memory before answering questions."
),
)
# Session 1: store preferences
result = await agent.run(
task="I'm a data scientist. I use Python, SQL, and VS Code with dark mode.",
)
# Wait for Hindsight to finish processing (fact extraction is async)
await asyncio.sleep(3)
# Session 2: recall from memory (same bank, memory persists)
result = await agent.run(
task="What IDE do I use?",
)
print(result.messages[-1].content)
# → "You use VS Code with dark mode."
# Clean up
await client.aclose()
await model_client.close()
asyncio.run(main())
```
Three tools, one bank. Memory persists across conversations because it's stored in Hindsight, not in the agent.
## Per-user memory banks
Parameterize `bank_id` for per-user isolation:
```python
def create_agent_for_user(user_id: str) -> AssistantAgent:
tools = create_hindsight_tools(
client=client,
bank_id=f"user-{user_id}",
)
return AssistantAgent(
name="assistant",
model_client=OpenAIChatCompletionClient(model="gpt-4o-mini"),
tools=tools,
)
```
Each bank is fully isolated; no cross-user data leakage.
## When to use this
- **Repeat-user agents** — Support bots, coding assistants, personal AI that should remember preferences and history across sessions
- **Multi-agent teams with shared memory** — A coordinator agent retains findings from group chats so future sessions start with context
- **Long-running workflows** — Agents that process data over days/weeks and need to accumulate knowledge incrementally
- **Personalization** — Any agent where "remembering the user" improves quality over time
## When NOT to use this
Be explicit: persistent memory isn't always the right tool.
- **In-session context only** — If your agent only needs to remember things within a single conversation, AutoGen's built-in chat history is simpler and has zero latency overhead. Don't add Hindsight just because you can.
- **Document search (RAG)** — If you need vector search over a document corpus, use a dedicated vector store. Hindsight is a memory system for facts learned over time, not a document store.
- **Ephemeral agents** — If each agent invocation is stateless by design (batch processing, one-shot tasks), persistent memory adds complexity without benefit.
- **Latency-critical hot paths** — Each memory operation adds a network round-trip. If sub-100ms response time matters more than personalization, skip it.
## Pitfalls and edge cases
**Bank must exist first.** Call `await client.acreate_bank(bank_id, name=...)` before the agent starts. If the bank doesn't exist, retain/recall will fail.
**Async processing delay.** After `hindsight_retain`, Hindsight processes content asynchronously, extracting facts, entities, embeddings. If you retain and immediately recall, the new memories may not be searchable yet. In practice, 1-3 seconds.
**Budget tuning.** Default `budget="mid"` balances speed and thoroughness. Use `"low"` for latency-sensitive agents, `"high"` for deep analysis. Budget controls how many retrieval strategies run and how much reranking happens.
**Reflect vs recall.** Use `hindsight_recall` for raw facts ("What IDE do I use?"). Use `hindsight_reflect` for synthesis ("Based on everything you know, what should I prioritize?"). Reflect is slower but produces reasoned answers that draw on the full knowledge graph.
## How this compares
**vs. AutoGen chat history:** Chat history stores raw messages in-session. It doesn't extract facts, doesn't generalize, and disappears when the conversation ends. Hindsight extracts structured facts, deduplicates, and retrieves only what's relevant — it compresses knowledge rather than accumulating tokens.
**vs. raw vector stores (Pinecone, Weaviate, Chroma):** A vector store gives you embedding similarity search. Hindsight runs four parallel retrieval strategies (semantic, BM25, graph traversal, temporal) with cross-encoder reranking, plus it extracts entities, resolves coreferences, and builds a knowledge graph. It's a memory engine, not a database. For independent benchmark results on what that architecture achieves at scale, see [Hindsight on BEAM](https://hindsight.vectorize.io/blog/2026/04/02/beam-sota).
**vs. other framework integrations:** If you're using LlamaIndex, LangGraph, CrewAI, or Pydantic AI instead of AutoGen, Hindsight has dedicated integrations for each: [LlamaIndex](/sdks/integrations/llamaindex), [LangGraph](/sdks/integrations/langgraph), [CrewAI](/sdks/integrations/crewai), [Pydantic AI](/sdks/integrations/pydantic-ai).
## Recap
- `hindsight-autogen` gives AutoGen agents persistent memory via `FunctionTool` instances passed to `AssistantAgent(tools=[...])`
- Three tools: `hindsight_retain` (store), `hindsight_recall` (search), `hindsight_reflect` (synthesize)
- Works with any AutoGen `AssistantAgent`, single agents or multi-agent teams
- Per-user banks for memory isolation, tags for scoping, budget for speed/depth tradeoff
## Next steps
- **Try it locally:** `pip install hindsight-all hindsight-autogen autogen-agentchat "autogen-ext[openai]"` and run the example above
- **Use Hindsight Cloud:** Skip self-hosting with a [free account](https://ui.hindsight.vectorize.io/signup)
- **Benchmark results:** [Why Hindsight leads on BEAM at 10M tokens](https://hindsight.vectorize.io/blog/2026/04/02/beam-sota)
- **Explore other integrations:** [LlamaIndex](/sdks/integrations/llamaindex), [LangGraph](/sdks/integrations/langgraph), [Pydantic AI](/sdks/integrations/pydantic-ai), [CrewAI](/sdks/integrations/crewai)
@@ -0,0 +1,127 @@
---
title: "Hindsight Is Now a Native Memory Provider in Hermes Agent"
authors: [benfrank241]
date: 2026-04-06
tags: [hermes, memory, hindsight, integration]
description: "Hermes Agent now supports pluggable memory providers. Here's why Hindsight is the backend to use, and how to set it up in two minutes."
image: /img/blog/hermes-native-memory-provider.png
hide_table_of_contents: true
---
![Hindsight is now a native memory provider in Hermes Agent](/img/blog/hermes-native-memory-provider.png)
Hermes Agent now ships with a pluggable memory provider system. Hindsight is one of the supported backends, and it's the one that leads on [the benchmark that actually tests memory at scale](/blog/2026/04/02/beam-sota).
<!-- truncate -->
---
## How It Works
Hindsight integrates at two points in the Hermes lifecycle:
**Before each turn**, Hindsight queues an async prefetch. Relevant memories from your past sessions are retrieved and injected into the system prompt before the LLM sees your message. The model has context from previous conversations without you repeating yourself.
**After each response**, your conversation is retained asynchronously. Hindsight extracts facts, entities, and relationships in the background. What you say in this turn becomes searchable starting the next call.
This is intentional: the prefetch pattern means **memories from the current turn won't appear until the next one**. It keeps every call fast.
---
## Why Hindsight on Hermes
Hermes ships with a built-in memory tool that saves notes to local markdown files. It works, but it captures what the model explicitly decides to write down, not what it implicitly learns from your conversations. Context doesn't accumulate automatically. If you ask Hermes to help you plan a sprint on Monday and then open a new session on Friday, it doesn't remember the project, the team, or the deadline unless you re-establish that context yourself.
Hindsight solves this with persistent memory across conversations. You mention a product launch deadline once. A week later, in a new session on a different topic, Hermes already knows it. You didn't repeat yourself. You didn't paste in context. It was recalled automatically.
Of all the supported memory providers, Hindsight is the only one with published results on [BEAM](/blog/2026/04/02/beam-sota), the benchmark that tests memory at 10 million tokens, where context stuffing is physically impossible. Hindsight scores 64.1% at that tier. The next-best published result is 40.6%.
---
## Setting It Up
Setup is a single wizard command:
```bash
hermes memory setup # select "hindsight"
```
Then confirm memory is active:
```bash
hermes memory status
```
Config lives at `$HERMES_HOME/hindsight/config.json`:
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `cloud` | `cloud` or `local` |
| `bank_id` | `hermes` | Memory bank identifier |
| `budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` |
| `memory_mode` | `hybrid` | `hybrid`, `context`, or `tools` — see below |
| `prefetch_method` | `recall` | `recall` (fast) or `reflect` (LLM-synthesized) |
---
## Memory Modes
Auto-recall is the core behavior: before every turn, Hindsight automatically fetches relevant memories from your history and injects them into the system prompt. Hermes has the context it needs without the model calling any tool and without you repeating yourself. It happens transparently on every call.
The `memory_mode` setting controls whether auto-recall is active and whether explicit tools are also exposed:
| Mode | Behavior |
|------|----------|
| `hybrid` (default) | Memories auto-injected before every turn, plus `hindsight_recall`, `hindsight_retain`, and `hindsight_reflect` tools exposed to the model |
| `context` | Auto-recall only — memories injected automatically, no tools visible to the model |
| `tools` | Explicit tools only — model must call `hindsight_recall` to retrieve memories; nothing is injected automatically |
`prefetch_method` controls how memories are retrieved during auto-recall:
- **`recall`** (default): semantic search, keyword matching, entity graph traversal, and reranking. Fast.
- **`reflect`**: LLM synthesizes a coherent summary across all relevant memories. Slower, but more useful for complex context.
---
## Migrating from the Old Plugin
If you previously installed `hindsight-hermes` as a pip plugin (the approach from our [earlier guide](/blog/2026/03/17/hermes-agent-memory)), uninstall it first:
```bash
uv pip uninstall hindsight-hermes --python $HOME/.hermes/hermes-agent/venv/bin/python
```
Then run the setup wizard to configure the native provider:
```bash
hermes memory setup
```
The native provider replaces everything the plugin did, with better lifecycle management and the full `memory_mode` and `prefetch_method` controls.
---
## Local or Cloud
In local mode, Hindsight runs an embedded server with built-in PostgreSQL. The daemon starts automatically in the background on first use; no manual setup required. You need an LLM API key for memory extraction:
```json
{
"mode": "local",
"llm_provider": "groq",
"llm_api_key": "your-groq-key"
}
```
The daemon starts when Hermes displays "starting agent" on your first message — not at launch. On a fresh system this can take over a minute while the embedded PostgreSQL server initializes. Subsequent startups are fast. Startup logs land at `~/.hermes/logs/hindsight-embed.log` if you need to debug.
For persistent memory across machines or shared across multiple Hermes instances, use cloud mode instead. Both modes use the same API. Switching is a one-line config change, not a migration.
---
## Get Started
- **Hermes integration docs**: [/sdks/integrations/hermes](/sdks/integrations/hermes)
- **BEAM benchmark results**: [Hindsight Is #1 on BEAM](/blog/2026/04/02/beam-sota)
- **Quick start**: [/developer/api/quickstart](/developer/api/quickstart)
- **GitHub**: [github.com/vectorize-io/hindsight](https://github.com/vectorize-io/hindsight)
- **Cloud**: [ui.hindsight.vectorize.io/signup](https://ui.hindsight.vectorize.io/signup)
@@ -1,5 +1,7 @@
---
sidebar_position: 12
title: "AutoGen Persistent Memory with Hindsight | Integration Guide"
description: "Add long-term memory to AutoGen agents with Hindsight. Provides FunctionTool instances for retain, recall, and reflect that plug directly into AutoGen's AssistantAgent."
---
# AutoGen
@@ -10,26 +10,29 @@ Persistent long-term memory for [Hermes Agent](https://github.com/NousResearch/h
## Quick Start
**1. Get an API key** at [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect). The API endpoint is `https://api.hindsight.vectorize.io`.
**2. Run the setup wizard:**
```bash
# 1. Install the plugin into Hermes's Python environment
uv pip install hindsight-hermes --python $HOME/.hermes/hermes-agent/venv/bin/python
hermes memory setup # select "hindsight"
```
# 2. Configure (choose one)
# Option A: Config file (recommended)
mkdir -p ~/.hindsight
cat > ~/.hindsight/hermes.json << 'EOF'
{
"hindsightApiUrl": "http://localhost:9077",
"bankId": "hermes"
}
EOF
The wizard will prompt for your API key and API URL, and configure everything automatically.
# Option B: Environment variables
export HINDSIGHT_API_URL=http://localhost:9077
export HINDSIGHT_BANK_ID=hermes
Or configure manually:
# 3. Start Hermes — the plugin activates automatically
hermes
```bash
hermes config set memory.provider hindsight
# Add your key and the API endpoint
echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env
echo "HINDSIGHT_API_URL=https://api.hindsight.vectorize.io" >> ~/.hermes/.env
```
**3. Confirm memory is active:**
```bash
hermes memory status
```
## Features
@@ -37,8 +40,8 @@ hermes
- **Auto-recall** — on every turn, queries Hindsight for relevant memories and injects them into the system prompt (via `pre_llm_call` hook)
- **Auto-retain** — after every response, retains the user/assistant exchange to Hindsight (via `post_llm_call` hook)
- **Explicit tools**`hindsight_retain`, `hindsight_recall`, `hindsight_reflect` for direct model control
- **Config file**`~/.hindsight/hermes.json` with the same field names as openclaw and claude-code integrations
- **Zero config overhead** — env vars still work as overrides for CI/automation
- **Memory modes** — choose between automatic injection, tools-only, or hybrid
- **Zero config overhead** — env vars work as overrides for CI/automation
:::note
The lifecycle hooks (`pre_llm_call`/`post_llm_call`) require hermes-agent with [PR #2823](https://github.com/NousResearch/hermes-agent/pull/2823) or later. On older versions, only the three tools are registered — hooks are silently skipped.
@@ -58,60 +61,70 @@ The plugin registers via Hermes's `hermes_agent.plugins` entry point system:
## Connection Modes
### 1. External API (recommended for production)
### 1. Cloud (recommended for production)
Connect to a running Hindsight server (cloud or self-hosted). No local LLM needed — the server handles fact extraction.
Connect to Hindsight Cloud at `https://api.hindsight.vectorize.io`. Get an API key at [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect).
```json
{
"hindsightApiUrl": "https://your-hindsight-server.com",
"hindsightApiToken": "your-token",
"bankId": "hermes"
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "hsk_your_token",
"bank_id": "hermes"
}
```
### 2. Local Daemon
### 2. Local (embedded)
If you're running `hindsight-embed` locally, point to it:
Runs an embedded Hindsight server with built-in PostgreSQL. Requires an LLM API key for memory extraction and synthesis. The daemon starts automatically in the background on first use.
```json
{
"hindsightApiUrl": "http://localhost:9077",
"bankId": "hermes"
"mode": "local",
"llm_provider": "groq",
"llm_api_key": "your-groq-key"
}
```
Follow the [Quick Start](/developer/api/quickstart) guide to get the Hindsight API running.
:::note
The embedded server starts on the first message when Hermes says "starting agent". On a fresh system this can take over a minute while the embedded PostgreSQL initializes. Subsequent startups are fast.
:::
Daemon startup logs: `~/.hermes/logs/hindsight-embed.log`
Daemon runtime logs: `~/.hindsight/profiles/<profile>.log`
## Configuration
All settings are in `~/.hindsight/hermes.json`. Every setting can also be overridden via environment variables (env vars take priority).
All settings are in `~/.hermes/hindsight/config.json`. Every setting can also be overridden via environment variables (env vars take priority).
### Connection & Daemon
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | — | `HINDSIGHT_API_URL` | Hindsight API URL |
| `hindsightApiToken` | `null` | `HINDSIGHT_API_TOKEN` / `HINDSIGHT_API_KEY` | Auth token for API |
| `mode` | `cloud` | `HINDSIGHT_MODE` | `cloud` or `local` |
| `api_url` | `https://api.hindsight.vectorize.io` | `HINDSIGHT_API_URL` | Hindsight API URL |
| `api_key` | `null` | `HINDSIGHT_API_KEY` | Auth token for Hindsight Cloud |
| `apiPort` | `9077` | `HINDSIGHT_API_PORT` | Port for local Hindsight daemon |
| `daemonIdleTimeout` | `0` | `HINDSIGHT_DAEMON_IDLE_TIMEOUT` | Seconds before idle daemon shuts down (0 = never) |
| `embedVersion` | `"latest"` | `HINDSIGHT_EMBED_VERSION` | `hindsight-embed` version for `uvx` |
### LLM Provider (daemon mode only)
### LLM Provider (local mode only)
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `llmProvider` | auto-detect | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama` |
| `llmModel` | provider default | `HINDSIGHT_LLM_MODEL` | Model override |
| `llm_provider` | `openai` | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama`, `lmstudio` |
| `llm_api_key` | — | `HINDSIGHT_LLM_API_KEY` | API key for the chosen LLM provider |
| `llm_model` | provider default | `HINDSIGHT_LLM_MODEL` | Model override (auto-defaults per provider) |
Default models per provider: `openai``gpt-4o-mini`, `anthropic``claude-haiku-4-5`, `gemini``gemini-2.5-flash`, `groq``openai/gpt-oss-120b`, `minimax``MiniMax-M2.7`, `ollama``gemma3:12b`.
### Memory Bank
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `bankId` | | `HINDSIGHT_BANK_ID` | Memory bank ID |
| `bank_id` | `hermes` | `HINDSIGHT_BANK_ID` | Memory bank ID |
| `bankMission` | `""` | `HINDSIGHT_BANK_MISSION` | Agent identity/purpose for the memory bank |
| `retainMission` | `null` | — | Custom retain mission (what to extract from conversations) |
| `bankIdPrefix` | `""` | — | Prefix for all bank IDs |
### Auto-Recall
@@ -135,6 +148,22 @@ Default preamble:
| `retainOverlapTurns` | `2` | — | Extra overlap turns for continuity |
| `retainRoles` | `["user", "assistant"]` | — | Which message roles to retain |
### Integration Mode
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `memory_mode` | `hybrid` | — | How memories are integrated into the agent (see below) |
| `prefetch_method` | `recall` | — | Method used for automatic context injection (see below) |
**memory_mode:**
- `hybrid` — automatic context injection before each turn, plus tools available to the LLM
- `context` — automatic injection only; no tools exposed to the model
- `tools` — tools only (`hindsight_retain`, `hindsight_recall`, `hindsight_reflect`); no automatic injection
**prefetch_method:**
- `recall` — injects raw memory facts into the system prompt (fast)
- `reflect` — injects an LLM-synthesized summary of relevant memories (slower, more coherent)
### Miscellaneous
| Setting | Default | Env Var | Description |
@@ -167,11 +196,16 @@ print(list(eps))
```
You should see `EntryPoint(name='hindsight', value='hindsight_hermes', ...)`.
**Tools don't appear in `/tools`**: Check that `hindsightApiUrl` (or `HINDSIGHT_API_URL`) is set. The plugin silently skips registration when unconfigured.
**Tools don't appear in `/tools`**: Check that `api_url` (or `HINDSIGHT_API_URL`) is set, or that `HINDSIGHT_API_KEY` is set for cloud mode. The plugin silently skips tool registration when unconfigured.
**Connection refused**: Verify the Hindsight API is running:
```bash
curl http://localhost:9077/health
```
**Local daemon not starting**: Check the daemon log for errors:
```bash
cat ~/.hermes/logs/hindsight-embed.log
```
**Recall returning no memories**: Memories need at least one retain cycle. Try storing a fact first, then asking about it in a new session.
@@ -0,0 +1,141 @@
---
sidebar_position: 20
title: "OpenCode Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to OpenCode with Hindsight. Automatically captures conversations and recalls relevant context across coding sessions."
---
# OpenCode
Persistent long-term memory plugin for [OpenCode](https://opencode.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations, recalls relevant context on session start, and provides retain/recall/reflect tools the agent can call directly.
## Quick Start
```bash
# 1. Install the plugin
npm install @vectorize-io/opencode-hindsight
```
Add to your `opencode.json`:
```json
{
"plugin": ["@vectorize-io/opencode-hindsight"]
}
```
```bash
# 2. Configure your Hindsight server
export HINDSIGHT_API_URL="http://localhost:8888"
# Optional: API key for Hindsight Cloud
export HINDSIGHT_API_TOKEN="your-api-key"
# 3. Start OpenCode — the plugin activates automatically
opencode
```
## Features
### Custom Tools
The plugin registers three tools the agent can call explicitly:
| Tool | Description |
|---|---|
| `hindsight_retain` | Store information in long-term memory |
| `hindsight_recall` | Search long-term memory for relevant information |
| `hindsight_reflect` | Generate a synthesized answer from long-term memory |
### Auto-Retain
When the session goes idle (`session.idle` event), the plugin automatically retains the conversation transcript to Hindsight. Configurable via `retainEveryNTurns` to control frequency.
### Session Recall
When a new session starts, the plugin recalls relevant project context and injects it into the system prompt, giving the agent access to memories from prior sessions.
### Compaction Hook
When OpenCode compacts the context window, the plugin:
1. Retains the current conversation before compaction
2. Recalls relevant memories and injects them into the compaction context
This ensures memories survive context window trimming.
## Configuration
### Plugin Options
```json
{
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid",
"retainEveryNTurns": 10,
"debug": false
}]
]
}
```
### Config File
Create `~/.hindsight/opencode.json` for persistent configuration that applies across all projects:
```json
{
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"recallBudget": "mid"
}
```
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | *(required)* |
| `HINDSIGHT_API_TOKEN` | API key for authentication | |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context for reflect | |
| `HINDSIGHT_DEBUG` | Enable debug logging to stderr | `false` |
Configuration priority (later wins): defaults < `~/.hindsight/opencode.json` < plugin options < env vars.
## Dynamic Bank IDs
For multi-project isolation, enable dynamic bank ID derivation:
```bash
export HINDSIGHT_DYNAMIC_BANK_ID=true
```
The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`.
For multi-user scenarios (e.g., shared agent serving multiple users):
```bash
export HINDSIGHT_CHANNEL_ID="slack-general"
export HINDSIGHT_USER_ID="user123"
```
## How It Works
1. **Plugin loads** when OpenCode starts — creates a `HindsightClient`, derives the bank ID, and registers tools + hooks
2. **Session starts**`session.created` event triggers, plugin marks session for recall injection
3. **System transform** — on the first LLM call, recalled memories are injected into the system prompt
4. **Agent works** — can call `hindsight_recall` and `hindsight_retain` explicitly during the session
5. **Session idles**`session.idle` event triggers auto-retain of the conversation
6. **Compaction** — if the context window fills up, memories are preserved through the compaction
@@ -1,5 +1,7 @@
---
sidebar_position: 11
title: "Paperclip Persistent Memory with Hindsight | Integration Guide"
description: "Add long-term memory to Paperclip agents with Hindsight. Retain, recall, and reflect memories across sessions using the Paperclip integration."
---
# Paperclip
+33 -2
View File
@@ -352,7 +352,7 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `cohere`, `litellm`, or `litellm-sdk` | `local` |
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `cohere`, `google`, `litellm`, or `litellm-sdk` | `local` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU` | Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS) | `false` |
@@ -370,6 +370,12 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL` | LiteLLM SDK embedding model (use provider prefix, e.g., `cohere/embed-english-v3.0`) | `cohere/embed-english-v3.0` |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE` | Custom base URL for LiteLLM SDK embeddings (optional) | - |
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS` | Optional output embedding dimensions (provider-dependent, e.g., `768` for Gemini embedding models) | - |
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY` | Gemini API key for embeddings (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL` | Gemini embedding model | `gemini-embedding-001` |
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY` | Output embedding dimensions (Gemini supports configurable dimensionality) | `768` |
| `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID` | Vertex AI project ID for embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID`) | - |
| `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION` | Vertex AI region for embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_REGION`) | - |
| `HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY` | Service account key for Vertex AI embeddings (falls back to `HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY`) | - |
```bash
# Local (default) - uses SentenceTransformers
@@ -413,6 +419,19 @@ export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE=http://localhost:4000
export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY=your-litellm-key # optional
export HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL=text-embedding-3-small # or cohere/embed-english-v3.0
# Google - Gemini API (API key auth)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY=xxxxxxxxxxxx # or reuses HINDSIGHT_API_LLM_API_KEY
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001 # 768 dimensions (default)
# export HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY=768 # configurable: 256, 512, 768, 1024, etc.
# Google - Vertex AI auth (auto-detected when project ID is set)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001
export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID=your-gcp-project-id # falls back to HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
# export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION=us-central1 # falls back to HINDSIGHT_API_LLM_VERTEXAI_REGION
# export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/key.json # falls back to LLM config, or uses ADC
# LiteLLM SDK - direct API access without proxy server (recommended)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm-sdk
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY=your-provider-api-key
@@ -444,13 +463,15 @@ Supported OpenAI embedding dimensions:
- `text-embedding-3-small`: 1536 dimensions
- `text-embedding-3-large`: 3072 dimensions
- `text-embedding-ada-002`: 1536 dimensions (legacy)
Google's `gemini-embedding-001` produces 3072 dimensions natively but supports configurable output dimensionality. Set `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY` to control the output size (default: 768).
:::
### Reranker
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, `cohere`, `zeroentropy`, `flashrank`, `litellm`, `litellm-sdk`, `jina-mlx`, or `rrf` | `local` |
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, `cohere`, `zeroentropy`, `google`, `flashrank`, `litellm`, `litellm-sdk`, `jina-mlx`, or `rrf` | `local` |
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
| `HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT` | Max concurrent local reranking (prevents CPU thrashing under load) | `4` |
| `HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
@@ -474,6 +495,9 @@ Supported OpenAI embedding dimensions:
| `HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY` | ZeroEntropy API key for reranking | - |
| `HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL` | ZeroEntropy rerank model (`zerank-2`, `zerank-2-small`) | `zerank-2` |
| `HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL` | Custom base URL for ZeroEntropy-compatible API (e.g., mock server, proxy, or self-hosted deployment) | `https://api.zeroentropy.dev` |
| `HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID` | Google Cloud project ID for Discovery Engine reranking (falls back to `HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID`) | - |
| `HINDSIGHT_API_RERANKER_GOOGLE_MODEL` | Google Discovery Engine ranking model | `semantic-ranker-default-004` |
| `HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY` | Path to service account JSON key (falls back to `HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY`). If unset, uses ADC. | - |
| `HINDSIGHT_API_RERANKER_FLASHRANK_MODEL` | FlashRank model for fast CPU-based reranking | `ms-marco-MiniLM-L-12-v2` |
| `HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR` | Cache directory for FlashRank models | System default |
| `HINDSIGHT_API_RERANKER_JINA_MLX_MODEL_PATH` | Local path to downloaded `jina-reranker-v3-mlx` model (auto-downloads from HuggingFace if unset) | - |
@@ -521,6 +545,12 @@ export HINDSIGHT_API_RERANKER_PROVIDER=litellm-sdk
export HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY=your-deepinfra-api-key
export HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL=deepinfra/Qwen3-reranker-8B # or cohere/rerank-english-v3.0, etc.
# Google Discovery Engine - cloud-based semantic reranking
export HINDSIGHT_API_RERANKER_PROVIDER=google
export HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID=your-gcp-project-id
export HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY=/path/to/service-account.json # optional, uses ADC if unset
export HINDSIGHT_API_RERANKER_GOOGLE_MODEL=semantic-ranker-default-004 # or semantic-ranker-fast-004
# Jina MLX - Apple Silicon native reranking (no GPU/cloud required)
# Model (~1.2 GB) is downloaded automatically from HuggingFace Hub on first use.
export HINDSIGHT_API_RERANKER_PROVIDER=jina-mlx
@@ -1070,6 +1100,7 @@ Hindsight provides OpenTelemetry-based observability for LLM calls, conforming t
| `HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS` | Headers for OTLP exporter (format: "key1=value1,key2=value2") | - |
| `HINDSIGHT_API_OTEL_SERVICE_NAME` | Service name for traces | `hindsight-api` |
| `HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT` | Deployment environment name (e.g., development, staging, production) | `development` |
| `HINDSIGHT_API_METRICS_INCLUDE_BANK_ID` | Include `bank_id` in OTel metric attributes. Enable only for deployments with few banks — high cardinality causes unbounded memory growth. | `false` |
**Features:**
- Full prompts and completions recorded as events
+19
View File
@@ -376,6 +376,7 @@ Converts text into dense vector representations for semantic similarity search.
| `local` | SentenceTransformers (default) | Development, low latency |
| `openai` | OpenAI embeddings API | Production, high quality |
| `cohere` | Cohere embeddings API | Production, multilingual |
| `google` | Google embeddings (Gemini API or Vertex AI) | Production, multilingual, high quality |
| `tei` | HuggingFace Text Embeddings Inference | Production, self-hosted |
| `litellm` | LiteLLM proxy (unified gateway) | Multi-provider setups |
@@ -394,6 +395,14 @@ Converts text into dense vector representations for semantic similarity search.
| `text-embedding-3-large` | 3072 | Higher quality, more expensive |
| `text-embedding-ada-002` | 1536 | Legacy model |
### Google Models
| Model | Dimensions | Use Case |
|-------|------------|----------|
| `gemini-embedding-001` | 768 (configurable) | Default Google, general purpose |
Google's `gemini-embedding-001` supports configurable output dimensionality via truncation, google recommend using: 768, 1536, 3072, via `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY`. Default is 768.
### Cohere Models
| Model | Dimensions | Use Case |
@@ -422,6 +431,16 @@ export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=your-api-key
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0
# Google (API key auth)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY=xxxxxxxxxxxx
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001
# Google (Vertex AI auth - auto-detected when project ID is set)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=google
export HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL=gemini-embedding-001
export HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID=your-gcp-project-id
# TEI (self-hosted)
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei
export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
@@ -1,177 +0,0 @@
---
sidebar_position: 10
title: "Hermes Agent Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to Hermes Agent with Hindsight. Automatically recalls context before every LLM call and retains conversations for future sessions."
---
# Hermes Agent
Persistent long-term memory for [Hermes Agent](https://github.com/NousResearch/hermes-agent) using [Hindsight](https://vectorize.io/hindsight). Automatically recalls relevant context before every LLM call and retains conversations for future sessions — plus explicit retain/recall/reflect tools.
## Quick Start
```bash
# 1. Install the plugin into Hermes's Python environment
uv pip install hindsight-hermes --python $HOME/.hermes/hermes-agent/venv/bin/python
# 2. Configure (choose one)
# Option A: Config file (recommended)
mkdir -p ~/.hindsight
cat > ~/.hindsight/hermes.json << 'EOF'
{
"hindsightApiUrl": "http://localhost:9077",
"bankId": "hermes"
}
EOF
# Option B: Environment variables
export HINDSIGHT_API_URL=http://localhost:9077
export HINDSIGHT_BANK_ID=hermes
# 3. Start Hermes — the plugin activates automatically
hermes
```
## Features
- **Auto-recall** — on every turn, queries Hindsight for relevant memories and injects them into the system prompt (via `pre_llm_call` hook)
- **Auto-retain** — after every response, retains the user/assistant exchange to Hindsight (via `post_llm_call` hook)
- **Explicit tools** — `hindsight_retain`, `hindsight_recall`, `hindsight_reflect` for direct model control
- **Config file** — `~/.hindsight/hermes.json` with the same field names as openclaw and claude-code integrations
- **Zero config overhead** — env vars still work as overrides for CI/automation
:::note
The lifecycle hooks (`pre_llm_call`/`post_llm_call`) require hermes-agent with [PR #2823](https://github.com/NousResearch/hermes-agent/pull/2823) or later. On older versions, only the three tools are registered — hooks are silently skipped.
:::
## Architecture
The plugin registers via Hermes's `hermes_agent.plugins` entry point system:
| Component | Purpose |
|-----------|---------|
| `pre_llm_call` hook | **Auto-recall** — query memories, inject as ephemeral system prompt context |
| `post_llm_call` hook | **Auto-retain** — store user/assistant exchange to Hindsight |
| `hindsight_retain` tool | Explicit memory storage (model-initiated) |
| `hindsight_recall` tool | Explicit memory search (model-initiated) |
| `hindsight_reflect` tool | LLM-synthesized answer from stored memories |
## Connection Modes
### 1. External API (recommended for production)
Connect to a running Hindsight server (cloud or self-hosted). No local LLM needed — the server handles fact extraction.
```json
{
"hindsightApiUrl": "https://your-hindsight-server.com",
"hindsightApiToken": "your-token",
"bankId": "hermes"
}
```
### 2. Local Daemon
If you're running `hindsight-embed` locally, point to it:
```json
{
"hindsightApiUrl": "http://localhost:9077",
"bankId": "hermes"
}
```
Follow the [Quick Start](/developer/api/quickstart) guide to get the Hindsight API running.
## Configuration
All settings are in `~/.hindsight/hermes.json`. Every setting can also be overridden via environment variables (env vars take priority).
### Connection & Daemon
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | — | `HINDSIGHT_API_URL` | Hindsight API URL |
| `hindsightApiToken` | `null` | `HINDSIGHT_API_TOKEN` / `HINDSIGHT_API_KEY` | Auth token for API |
| `apiPort` | `9077` | `HINDSIGHT_API_PORT` | Port for local Hindsight daemon |
| `daemonIdleTimeout` | `0` | `HINDSIGHT_DAEMON_IDLE_TIMEOUT` | Seconds before idle daemon shuts down (0 = never) |
| `embedVersion` | `"latest"` | `HINDSIGHT_EMBED_VERSION` | `hindsight-embed` version for `uvx` |
### LLM Provider (daemon mode only)
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `llmProvider` | auto-detect | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama` |
| `llmModel` | provider default | `HINDSIGHT_LLM_MODEL` | Model override |
### Memory Bank
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `bankId` | — | `HINDSIGHT_BANK_ID` | Memory bank ID |
| `bankMission` | `""` | `HINDSIGHT_BANK_MISSION` | Agent identity/purpose for the memory bank |
| `retainMission` | `null` | — | Custom retain mission (what to extract from conversations) |
| `bankIdPrefix` | `""` | — | Prefix for all bank IDs |
### Auto-Recall
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `true` | `HINDSIGHT_AUTO_RECALL` | Enable automatic memory recall via `pre_llm_call` hook |
| `recallBudget` | `"mid"` | `HINDSIGHT_RECALL_BUDGET` | Recall effort: `low`, `mid`, `high` |
| `recallMaxTokens` | `4096` | `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens in recall response |
| `recallMaxQueryChars` | `800` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | Max chars of user message used as query |
| `recallPromptPreamble` | see below | — | Header text injected before recalled memories |
Default preamble:
> Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:
### Auto-Retain
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `true` | `HINDSIGHT_AUTO_RETAIN` | Enable automatic retention via `post_llm_call` hook |
| `retainEveryNTurns` | `1` | — | Retain every Nth turn |
| `retainOverlapTurns` | `2` | — | Extra overlap turns for continuity |
| `retainRoles` | `["user", "assistant"]` | — | Which message roles to retain |
### Miscellaneous
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `debug` | `false` | `HINDSIGHT_DEBUG` | Enable debug logging to stderr |
## Hermes Gateway (Telegram, Discord, Slack)
When using Hermes in gateway mode (multi-platform messaging), the plugin works across all platforms. Hermes creates a fresh `AIAgent` per message, and the plugin's `pre_llm_call` hook ensures relevant memories are recalled for each turn regardless of platform.
## Disabling Hermes's Built-in Memory
Hermes has a built-in `memory` tool that saves to local markdown files. If both are active, the LLM may prefer the built-in one. Disable it:
```bash
hermes tools disable memory
```
Re-enable later with `hermes tools enable memory`.
## Troubleshooting
**Plugin not loading**: Verify the entry point is registered:
```bash
python -c "
import importlib.metadata
eps = importlib.metadata.entry_points(group='hermes_agent.plugins')
print(list(eps))
"
```
You should see `EntryPoint(name='hindsight', value='hindsight_hermes', ...)`.
**Tools don't appear in `/tools`**: Check that `hindsightApiUrl` (or `HINDSIGHT_API_URL`) is set. The plugin silently skips registration when unconfigured.
**Connection refused**: Verify the Hindsight API is running:
```bash
curl http://localhost:9077/health
```
**Recall returning no memories**: Memories need at least one retain cycle. Try storing a fact first, then asking about it in a new session.
+12
View File
@@ -165,6 +165,18 @@ const config: Config = {
],
],
plugins: [
[
'@docusaurus/plugin-content-docs',
{
id: 'integrations',
path: './docs-integrations',
routeBasePath: 'sdks/integrations',
sidebarPath: false,
},
],
],
themes: [
'@docusaurus/theme-mermaid',
[
@@ -41,7 +41,7 @@ curl -X POST "$HINDSIGHT_URL/v1/default/banks/my-bank/import" \
# [docs:import-dry-run]
curl -X POST "$HINDSIGHT_URL/v1/default/banks/my-bank/import?dry_run=true" \
-H "Content-Type: application/json" \
-d @template.json
-d '{"version": "1", "bank": {"retain_mission": "Dry run test."}}'
# [/docs:import-dry-run]
# [docs:export-template]
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const integrationsDir = join(__dirname, '..', 'docs', 'sdks', 'integrations');
const integrationsDir = join(__dirname, '..', 'docs-integrations');
const IGNORED_FILES = ['_template.md', '_category_.json'];
@@ -55,7 +55,7 @@ for (const filename of files) {
if (violations.length > 0) {
console.error('[integration-seo] ❌ The following integration pages are missing required frontmatter:\n');
for (const { filename, missing } of violations) {
console.error(` docs/sdks/integrations/${filename} — missing: ${missing.join(', ')}`);
console.error(` docs-integrations/${filename} — missing: ${missing.join(', ')}`);
}
console.error('\nAll integration pages must have both `title` and `description` in their frontmatter.');
console.error('Example:\n');
+42 -36
View File
@@ -179,110 +179,116 @@ const sidebars: SidebarsConfig = {
collapsible: false,
items: [
{
type: 'doc',
id: 'sdks/integrations/local-mcp',
type: 'link',
href: '/sdks/integrations/local-mcp',
label: 'Local MCP Server',
customProps: { icon: '/img/icons/mcp.png' },
},
{
type: 'doc',
id: 'sdks/integrations/litellm',
type: 'link',
href: '/sdks/integrations/litellm',
label: 'LiteLLM',
customProps: { icon: '/img/icons/litellm.png' },
},
{
type: 'doc',
id: 'sdks/integrations/claude-code',
type: 'link',
href: '/sdks/integrations/claude-code',
label: 'Claude Code',
customProps: { icon: '/img/icons/claudecode.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/codex',
type: 'link',
href: '/sdks/integrations/codex',
label: 'OpenAI Codex CLI',
customProps: { icon: '/img/icons/terminal.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/openclaw',
type: 'link',
href: '/sdks/integrations/openclaw',
label: 'OpenClaw',
customProps: { icon: '/img/icons/openclaw.png' },
},
{
type: 'doc',
id: 'sdks/integrations/ai-sdk',
type: 'link',
href: '/sdks/integrations/ai-sdk',
label: 'Vercel AI SDK',
customProps: { icon: '/img/icons/vercel.png' },
},
{
type: 'doc',
id: 'sdks/integrations/chat',
type: 'link',
href: '/sdks/integrations/chat',
label: 'Vercel Chat SDK',
customProps: { icon: '/img/icons/vercel.png' },
},
{
type: 'doc',
id: 'sdks/integrations/crewai',
type: 'link',
href: '/sdks/integrations/crewai',
label: 'CrewAI',
customProps: { icon: '/img/icons/crewai.png' },
},
{
type: 'doc',
id: 'sdks/integrations/pydantic-ai',
type: 'link',
href: '/sdks/integrations/pydantic-ai',
label: 'Pydantic AI',
customProps: { icon: '/img/icons/pydanticai.png' },
},
{
type: 'doc',
id: 'sdks/integrations/agno',
type: 'link',
href: '/sdks/integrations/agno',
label: 'Agno',
customProps: { icon: '/img/icons/agno.png' },
},
{
type: 'doc',
id: 'sdks/integrations/hermes',
type: 'link',
href: '/sdks/integrations/hermes',
label: 'Hermes Agent',
customProps: { icon: '/img/icons/hermes.png' },
},
{
type: 'doc',
id: 'sdks/integrations/langgraph',
type: 'link',
href: '/sdks/integrations/langgraph',
label: 'LangGraph / LangChain',
customProps: { icon: '/img/icons/langgraph.png' },
},
{
type: 'doc',
id: 'sdks/integrations/nemoclaw',
type: 'link',
href: '/sdks/integrations/nemoclaw',
label: 'NemoClaw',
customProps: { icon: '/img/icons/nemoclaw.png' },
},
{
type: 'doc',
id: 'sdks/integrations/paperclip',
type: 'link',
href: '/sdks/integrations/paperclip',
label: 'Paperclip',
customProps: { icon: '/img/icons/nodejs.png' },
},
{
type: 'doc',
id: 'sdks/integrations/strands',
type: 'link',
href: '/sdks/integrations/strands',
label: 'Strands Agents',
customProps: { icon: '/img/icons/strands.png' },
},
{
type: 'doc',
id: 'sdks/integrations/ag2',
type: 'link',
href: '/sdks/integrations/ag2',
label: 'AG2',
customProps: { icon: '/img/icons/ag2.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/llamaindex',
type: 'link',
href: '/sdks/integrations/autogen',
label: 'AutoGen',
customProps: { icon: '/img/icons/autogen.svg' },
},
{
type: 'link',
href: '/sdks/integrations/llamaindex',
label: 'LlamaIndex',
customProps: { icon: '/img/icons/llamaindex.png' },
},
{
type: 'doc',
id: 'sdks/integrations/skills',
type: 'link',
href: '/sdks/integrations/skills',
label: 'Skills',
customProps: { icon: '/img/icons/skills.png' },
},
+10
View File
@@ -180,6 +180,16 @@
"link": "/sdks/integrations/autogen",
"icon": "/img/icons/autogen.svg"
},
{
"id": "opencode",
"name": "OpenCode",
"description": "Persistent long-term memory plugin for OpenCode. Auto-retains conversations, recalls context on session start, and provides retain/recall/reflect tools.",
"type": "official",
"by": "hindsight",
"category": "tool",
"link": "/sdks/integrations/opencode",
"icon": "/img/icons/opencode.svg"
},
{
"id": "hindclaw",
"name": "HindClaw",
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
<text x="32" y="42" font-family="monospace" font-size="28" font-weight="bold" fill="#00d4ff" text-anchor="middle">OC</text>
</svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -1,345 +0,0 @@
---
sidebar_position: 1
---
# LiteLLM
Universal LLM memory integration via [LiteLLM](https://github.com/BerriAI/litellm). Add persistent memory to any LLM application with just a few lines of code.
## Features
- **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more)
- **Simple Integration** - Just configure, enable, and use `hindsight_litellm.completion()`
- **Automatic Memory Injection** - Relevant memories are injected into prompts before LLM calls
- **Automatic Conversation Storage** - Conversations are stored to Hindsight for future recall
- **Two Memory Modes** - Choose between `reflect` (synthesized context) or `recall` (raw memory retrieval)
- **Direct Memory APIs** - Query, synthesize, and store memories manually
- **Native Client Wrappers** - Alternative wrappers for OpenAI and Anthropic SDKs
## Installation
```bash
pip install hindsight-litellm
```
## Quick Start
```python
import hindsight_litellm
# Configure and enable memory integration
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="my-agent",
)
hindsight_litellm.enable()
# Use the convenience wrapper - memory is automatically injected and stored
response = hindsight_litellm.completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What did we discuss about AI?"}]
)
```
## How It Works
When you call `completion()`, the following happens automatically:
1. **Memory Retrieval** - Hindsight is queried for relevant memories based on the conversation
2. **Prompt Injection** - Memories are injected into the system message
3. **LLM Call** - The enriched prompt is sent to the LLM
4. **Conversation Storage** - The conversation is stored to Hindsight for future recall
5. **Response Returned** - You receive the response as normal
## Configuration Options
```python
hindsight_litellm.configure(
# Required
hindsight_api_url="http://localhost:8888", # Hindsight API server URL
bank_id="my-agent", # Memory bank ID
api_key="your-api-key", # Optional API key for authentication
# Optional - Memory behavior
store_conversations=True, # Store conversations after LLM calls
inject_memories=True, # Inject relevant memories into prompts
use_reflect=False, # Use reflect API (synthesized) vs recall (raw memories)
reflect_include_facts=False, # Include source facts with reflect responses
max_memories=None, # Maximum memories to inject (None = unlimited)
max_memory_tokens=4096, # Maximum tokens for memory context
recall_budget="mid", # Recall budget: "low", "mid", "high"
fact_types=["world", "agent"], # Filter fact types to inject
# Optional - Bank Configuration
bank_name="My Agent", # Human-readable display name for the memory bank
background="This agent...", # Instructions guiding what Hindsight should remember
# Optional - Advanced
injection_mode="system_message", # or "prepend_user"
excluded_models=["gpt-3.5*"], # Exclude certain models
verbose=True, # Enable verbose logging and debug info
)
```
### Bank Configuration
The `background` and `bank_name` parameters configure the memory bank itself. When provided, `configure()` will automatically create or update the bank with these settings.
```python
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="support-router",
bank_name="Customer Support Router",
background="""This agent routes customer support requests to the appropriate team.
Remember which types of issues should go to which teams (billing, technical, sales).
Track customer preferences for communication channels and past issue resolutions.""",
)
```
### Memory Modes: Reflect vs Recall
- **Recall mode** (`use_reflect=False`, default): Retrieves raw memory facts and injects them as a numbered list. Best when you need precise, individual memories.
- **Reflect mode** (`use_reflect=True`): Synthesizes memories into a coherent context paragraph. Best for natural, conversational memory context.
```python
# Recall mode - raw memories
hindsight_litellm.configure(
bank_id="my-agent",
use_reflect=False, # Default
)
# Injects: "1. [WORLD] User prefers Python\n2. [OPINION] User dislikes Java..."
# Reflect mode - synthesized context
hindsight_litellm.configure(
bank_id="my-agent",
use_reflect=True,
)
# Injects: "Based on previous conversations, the user is a Python developer who..."
```
## Multi-Provider Support
Works with any LiteLLM-supported provider:
```python
import hindsight_litellm
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="my-agent",
)
hindsight_litellm.enable()
# OpenAI
hindsight_litellm.completion(model="gpt-4o", messages=[...])
# Anthropic
hindsight_litellm.completion(model="claude-3-5-sonnet-20241022", messages=[...])
# Groq
hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...])
# Azure OpenAI
hindsight_litellm.completion(model="azure/gpt-4", messages=[...])
# AWS Bedrock
hindsight_litellm.completion(model="bedrock/anthropic.claude-3", messages=[...])
# Google Vertex AI
hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=[...])
```
## Direct Memory APIs
### Recall - Query raw memories
```python
from hindsight_litellm import configure, recall
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
memories = recall("what projects am I working on?", budget="mid")
for m in memories:
print(f"- [{m.fact_type}] {m.text}")
```
### Reflect - Get synthesized context
```python
from hindsight_litellm import configure, reflect
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
result = reflect("what do you know about the user's preferences?")
print(result.text)
```
### Retain - Store memories
```python
from hindsight_litellm import configure, retain
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
result = retain(
content="User mentioned they're working on a machine learning project",
context="Discussion about current projects",
)
```
### Async APIs
```python
from hindsight_litellm import arecall, areflect, aretain
# Async versions of all memory APIs
memories = await arecall("what do you know about me?")
context = await areflect("summarize user preferences")
result = await aretain(content="New information to remember")
```
## Native Client Wrappers
Alternative to LiteLLM callbacks for direct SDK integration.
### OpenAI Wrapper
```python
from openai import OpenAI
from hindsight_litellm import wrap_openai
client = OpenAI()
wrapped = wrap_openai(
client,
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
)
response = wrapped.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What do you know about me?"}]
)
```
### Anthropic Wrapper
```python
from anthropic import Anthropic
from hindsight_litellm import wrap_anthropic
client = Anthropic()
wrapped = wrap_anthropic(
client,
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
)
response = wrapped.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}]
)
```
## Debug Mode
When `verbose=True`, you can inspect exactly what memories are being injected:
```python
from hindsight_litellm import configure, enable, completion, get_last_injection_debug
configure(
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
verbose=True,
)
enable()
response = completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What's my favorite color?"}]
)
# Inspect what was injected
debug = get_last_injection_debug()
if debug:
print(f"Mode: {debug.mode}") # "reflect" or "recall"
print(f"Injected: {debug.injected}") # True/False
print(f"Results: {debug.results_count}")
print(f"Memory context:\n{debug.memory_context}")
```
## Context Manager
```python
from hindsight_litellm import hindsight_memory
import litellm
with hindsight_memory(bank_id="user-123"):
response = litellm.completion(model="gpt-4", messages=[...])
# Memory integration automatically disabled after context
```
## Disabling and Cleanup
```python
from hindsight_litellm import disable, cleanup
# Temporarily disable memory integration
disable()
# Clean up all resources (call when shutting down)
cleanup()
```
## API Reference
### Main Functions
| Function | Description |
|----------|-------------|
| `configure(...)` | Configure global Hindsight settings |
| `enable()` | Enable memory integration with LiteLLM |
| `disable()` | Disable memory integration |
| `is_enabled()` | Check if memory integration is enabled |
| `cleanup()` | Clean up all resources |
### Configuration Functions
| Function | Description |
|----------|-------------|
| `get_config()` | Get current configuration |
| `is_configured()` | Check if Hindsight is configured |
| `reset_config()` | Reset configuration to defaults |
### Memory Functions
| Function | Description |
|----------|-------------|
| `recall(query, ...)` | Synchronously query raw memories |
| `arecall(query, ...)` | Asynchronously query raw memories |
| `reflect(query, ...)` | Synchronously get synthesized memory context |
| `areflect(query, ...)` | Asynchronously get synthesized memory context |
| `retain(content, ...)` | Synchronously store a memory |
| `aretain(content, ...)` | Asynchronously store a memory |
### Debug Functions
| Function | Description |
|----------|-------------|
| `get_last_injection_debug()` | Get debug info from last memory injection |
| `clear_injection_debug()` | Clear stored debug info |
### Client Wrappers
| Function | Description |
|----------|-------------|
| `wrap_openai(client, ...)` | Wrap OpenAI client with memory |
| `wrap_anthropic(client, ...)` | Wrap Anthropic client with memory |
## Requirements
- Python >= 3.10
- litellm >= 1.40.0
- A running Hindsight API server
@@ -1,193 +0,0 @@
---
sidebar_position: 2
---
# Local MCP Server
Hindsight provides a fully local MCP server that runs entirely on your machine with an embedded PostgreSQL database. No external server or database setup required.
This is ideal for:
- **Personal use with Claude Desktop** — Give Claude long-term memory across conversations
- **Development and testing** — Quick setup without infrastructure
- **Privacy-focused setups** — All data stays on your machine
## Quick Install
```bash
curl -fsSL https://hindsight.vectorize.io/get-mcp | bash -s -- \
--app claude-desktop \
--set HINDSIGHT_API_LLM_API_KEY=sk-...
```
This script will:
1. Install [uv](https://docs.astral.sh/uv/) if not already installed
2. Configure Claude Desktop to use the Hindsight MCP server
3. Set the provided environment variables in the MCP configuration
:::info Other MCP Applications
The quick install script currently supports Claude Desktop only. For other MCP-compatible applications (Cursor, Cline, etc.), follow the [Manual Configuration](#manual-configuration) steps below.
:::
## Manual Configuration
Add the following to your MCP client's configuration. For Claude Desktop:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
For other MCP clients, refer to their documentation for the configuration file location.
```json
{
"mcpServers": {
"hindsight": {
"command": "uvx",
"args": ["--from", "hindsight-api", "hindsight-local-mcp"],
"env": {
"HINDSIGHT_API_LLM_API_KEY": "sk-..."
}
}
}
}
```
### With Custom Bank ID
By default, memories are stored in a bank called `mcp`. To use a different bank:
```json
{
"mcpServers": {
"hindsight": {
"command": "uvx",
"args": ["--from", "hindsight-api", "hindsight-local-mcp"],
"env": {
"HINDSIGHT_API_LLM_API_KEY": "sk-...",
"HINDSIGHT_API_MCP_LOCAL_BANK_ID": "my-personal-memory"
}
}
}
}
```
## Environment Variables
All standard [Hindsight configuration variables](/developer/configuration) are supported.
### Local MCP Specific
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | No | `mcp` | Memory bank ID to use |
| `HINDSIGHT_API_MCP_INSTRUCTIONS` | No | - | Additional instructions appended to both `retain` and `recall` tools |
### Customizing Tool Behavior
You can customize what gets stored by adding instructions to the tools. Re-run the install script with the additional `--set` flag:
```bash
curl -fsSL https://hindsight.vectorize.io/get-mcp | bash -s -- \
--app claude-desktop \
--set HINDSIGHT_API_LLM_API_KEY=sk-... \
--set HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, code you write, and files you modify."
```
These instructions are appended to the default tool descriptions, guiding Claude on when and how to use the memory tools.
## Available Tools
### retain
Store information to long-term memory. This is a **fire-and-forget** operation — it returns immediately while processing happens in the background.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | Yes | The fact or memory to store |
| `context` | string | No | Category for the memory (default: `general`) |
**Example:**
```json
{
"name": "retain",
"arguments": {
"content": "User's favorite color is blue",
"context": "preferences"
}
}
```
**Response:**
```json
{
"status": "accepted",
"message": "Memory storage initiated"
}
```
### recall
Search memories to provide personalized responses.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Natural language search query |
| `max_tokens` | integer | No | Maximum tokens to return (default: 4096) |
| `budget` | string | No | Search depth: `low`, `mid`, or `high` (default: `low`) |
**Example:**
```json
{
"name": "recall",
"arguments": {
"query": "What are the user's color preferences?",
"max_tokens": 2048,
"budget": "mid"
}
}
```
## How It Works
The local MCP server:
1. **Starts an embedded PostgreSQL** (pg0) on an automatically assigned port
2. **Initializes the Hindsight memory engine** with local embeddings
3. **Connects via stdio** to Claude Code using the MCP protocol
Data is persisted in the pg0 data directory (`~/.pg0/hindsight-mcp/`), so your memories survive restarts.
## Troubleshooting
### "HINDSIGHT_API_LLM_API_KEY required"
Make sure you've set the API key in your MCP configuration:
```json
{
"env": {
"HINDSIGHT_API_LLM_API_KEY": "sk-..."
}
}
```
### Slow startup
The first startup may take longer as it:
- Downloads the embedding model (~100MB)
- Initializes the PostgreSQL database
Subsequent starts are faster.
### Checking logs
Set `HINDSIGHT_API_LOG_LEVEL=debug` for verbose output:
```json
{
"env": {
"HINDSIGHT_API_LOG_LEVEL": "debug"
}
}
```
Logs are written to stderr and visible in Claude Code's MCP server output.
@@ -1,323 +0,0 @@
---
sidebar_position: 3
---
# Skills
Hindsight provides an Agent Skill that gives AI coding assistants persistent memory across sessions. Skills are reusable prompt templates that agents can load when needed to gain specialized capabilities.
## Supported Platforms
| Platform | Skills Directory |
|----------|-----------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/skills/` |
| [OpenCode](https://github.com/opencode-ai/opencode) | `~/.opencode/skills/` |
| [Codex CLI](https://github.com/openai/codex) | `~/.codex/skills/` |
## Deployment Modes
The skill supports two deployment modes:
| Mode | Best For | Data Location |
|------|----------|---------------|
| **Local** | Individual developers | Your machine (`~/.pg0/`) |
| **Cloud** | Teams sharing knowledge | Hindsight Cloud |
## Quick Install
### Option 1: Interactive Installer (Recommended)
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
```
The installer will:
1. Prompt you to select your AI coding assistant
2. Select deployment mode (local or cloud)
3. Configure the appropriate settings
4. Install the skill to the appropriate directory
### Install for a Specific Platform
```bash
# Claude Code (interactive mode selection)
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude
# OpenCode
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app opencode
# Codex CLI
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app codex
```
### Install with Cloud Mode
```bash
# Direct cloud setup (skips interactive prompts for mode)
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude --mode cloud
```
### Option 2: Using add-skill
If you use [add-skill](https://add-skill.org/) to manage your agent skills:
```bash
# For local mode (individual developers)
npx add-skill vectorize-io/hindsight --skill hindsight-local
# For Hindsight Cloud (teams)
npx add-skill vectorize-io/hindsight --skill hindsight-cloud
# For self-hosted Hindsight servers
npx add-skill vectorize-io/hindsight --skill hindsight-self-hosted
```
On first use, the AI will guide you through the remaining setup:
- **Local**: Run `uvx hindsight-embed configure` to set up your LLM provider
- **Cloud**: Provide your API key and bank ID
- **Self-hosted**: Provide your server URL, API key, and bank ID
## What the Skill Provides
Once installed, your AI assistant gains the ability to:
- **Retain** - Store user preferences, learnings, and procedure outcomes
- **Recall** - Search for relevant context before starting tasks
- **Reflect** - Synthesize memories into contextual answers
The skill uses the `hindsight-embed` CLI which runs a lightweight local daemon with an embedded database.
## How Skills Work
Skills are **model-invoked**, meaning the AI assistant automatically decides when to use them based on the context of your conversation. You don't need to explicitly trigger the skill.
The assistant will:
- **Store** when you share preferences, when tasks succeed/fail, or when learnings emerge
- **Recall** before starting non-trivial tasks to get relevant context
### What Gets Stored
The skill is optimized to store:
| Category | Examples |
|----------|----------|
| **User Preferences** | Coding style, tool preferences, language choices |
| **Procedure Outcomes** | Commands that worked, configurations that resolved issues |
| **Learnings** | Bug solutions, workarounds, architecture decisions |
## Architecture
### Local Mode
```
AI Coding Assistant
Hindsight Skill (SKILL.md)
hindsight-embed CLI
Local Daemon (auto-started)
Embedded PostgreSQL (~/.pg0/hindsight-embed/)
```
All data stays on your machine. The daemon auto-starts when needed and shuts down after inactivity.
### Cloud Mode
```
AI Coding Assistant
Hindsight Skill (SKILL.md)
hindsight-cli
Hindsight Cloud API (https://api.hindsight.vectorize.io)
Shared Memory Bank (team-accessible)
```
Data is stored in Hindsight Cloud and shared across your team. All team members with the same bank ID can access shared memories.
---
## Local Mode Setup
The skill uses configuration stored in `~/.hindsight/config.env`. Reconfigure anytime:
```bash
uvx hindsight-embed configure
```
---
## Cloud Mode Setup
Cloud mode connects to [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup), allowing teams to share memories about a codebase. When one team member learns something, everyone benefits.
### Prerequisites
1. A Hindsight Cloud account ([sign up](https://ui.hindsight.vectorize.io/signup))
2. An API key from your team admin
3. A bank ID for your project (e.g., `team-acme-frontend`)
### Installation
Run the installer with cloud mode:
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --mode cloud
```
You'll be prompted for:
| Setting | Description | Example |
|---------|-------------|---------|
| **Cloud API URL** | Hindsight Cloud endpoint | `https://api.hindsight.vectorize.io` |
| **API Key** | Your authentication key | `hs_xxx...` |
| **Bank ID** | Shared memory bank for your team | `team-acme-frontend` |
### Configuration Files
Cloud mode creates two files:
**`~/.hindsight/config`** — API connection settings (TOML format):
```toml
api_url = "https://api.hindsight.vectorize.io"
api_key = "hs_xxx..."
```
**`~/.claude/skills/hindsight/SKILL.md`** — Skill definition with your bank ID baked in.
### Team Setup
To set up cloud mode for your team:
1. **Team admin** creates a bank in Hindsight Cloud (e.g., `team-acme-frontend`)
2. **Team admin** generates API keys for each team member
3. **Each developer** runs the installer with their API key and the shared bank ID
4. All team members now share the same memory bank
### What to Store in Team Banks
Cloud mode uses a **shared team bank**. Be thoughtful about what goes in:
| Type | Examples | How to Store |
|------|----------|--------------|
| **Project conventions** | Linting rules, testing requirements, Node version | `"Project uses ESLint with Airbnb config"` |
| **Team knowledge** | Architecture decisions, common pitfalls, domain logic | `"Auth module requires Redis 7+"` |
| **Individual preferences** | Personal coding style, communication preferences | `"Alice prefers verbose commit messages"` |
**Key distinction**: Project conventions apply to everyone. Individual preferences should include the person's name so the AI knows when to apply them.
### Example Workflow
```
Day 1: Alice discovers a requirement
─────────────────────────────────────
Alice's AI assistant stores:
"The auth module requires Redis 7+ due to HEXPIRE command usage"
"Alice prefers explicit error handling over silent failures"
Day 2: Bob starts working on auth
─────────────────────────────────
Bob's AI assistant recalls:
"The auth module requires Redis 7+ due to HEXPIRE command usage"
Bob avoids the same issue Alice hit!
(Alice's personal preference is stored but won't be applied to Bob)
```
### Testing Cloud Connection
After installation, verify the connection:
```bash
# Store a test memory
hindsight memory retain team-acme-frontend "Alice works at Google as a software engineer"
# Recall it
hindsight memory recall team-acme-frontend "Alice"
```
### Switching Between Banks
If you work on multiple projects, you can have different skills installed for each AI assistant, or manually switch banks:
```bash
# Environment variable override (temporary)
HINDSIGHT_API_URL=https://api.hindsight.vectorize.io \
HINDSIGHT_API_KEY=hs_xxx \
hindsight memory recall different-bank "query"
```
For permanent multi-bank setups, reinstall the skill with a different bank ID.
## Troubleshooting
### Skill not activating
The skill activates based on its description matching your request. Try being explicit:
- "Remember that..." triggers storage
- "What do you know about..." triggers recall
### Local Mode Issues
**Daemon not starting:**
```bash
uvx hindsight-embed daemon status
uvx hindsight-embed daemon logs
```
**Reconfigure LLM provider:**
```bash
uvx hindsight-embed configure
```
### Cloud Mode Issues
**Authentication errors:**
```bash
# Verify your config
cat ~/.hindsight/config
# Test connection manually
hindsight bank list
```
**Wrong bank ID:**
Check your SKILL.md file to see which bank ID is configured:
```bash
cat ~/.claude/skills/hindsight/SKILL.md | grep "memory retain"
```
To change the bank ID, reinstall the skill:
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --mode cloud
```
**Network/firewall issues:**
```bash
# Test connectivity to cloud API
curl -I https://api.hindsight.vectorize.io/health
```
## Requirements
### Local Mode
- Python 3.10+ (for `uvx`)
- An LLM API key (OpenAI, Anthropic, Groq, etc.)
### Cloud Mode
- Python 3.10+ (for `uvx`)
- Hindsight Cloud API key
- Network access to `https://api.hindsight.vectorize.io`
@@ -1,184 +0,0 @@
---
sidebar_position: 8
title: "AG2 (AutoGen) Persistent Memory with Hindsight | Integration Guide"
description: "Add long-term persistent memory to your AG2 (AutoGen) agents with Hindsight. Automatic fact extraction, entity tracking, and recall tools that persist across conversations."
---
# AG2
Persistent long-term memory for [AG2](https://ag2.ai) agents (community AutoGen fork). Give your agents retain/recall/reflect tools that persist across conversations.
[View Changelog →](/changelog/integrations/ag2)
## Features
- **Drop-in Tools**`register_hindsight_tools()` registers retain, recall, and reflect in one line
- **AG2-native** — Uses `Annotated` type hints compatible with AG2's `@register_for_llm` / `@register_for_execution` pattern
- **GroupChat Support** — Multiple agents can share a single memory bank
- **Selective Tools** — Include only the tools you need (`include_retain`, `include_recall`, `include_reflect`)
- **Simple Configuration** — Configure once globally or override per tool set
## Installation
```bash
pip install hindsight-ag2
```
## Quick Start
```python
from autogen import AssistantAgent, UserProxyAgent, LLMConfig
from hindsight_ag2 import register_hindsight_tools
llm_config = LLMConfig(api_type="openai", model="gpt-4o-mini")
with llm_config:
assistant = AssistantAgent(
name="assistant",
system_message="You are a helpful assistant with long-term memory.",
)
user_proxy = UserProxyAgent(
name="user",
human_input_mode="NEVER",
)
# Register Hindsight memory tools on both agents
register_hindsight_tools(
assistant, user_proxy,
bank_id="my-bank",
hindsight_api_url="http://localhost:8888",
)
# The assistant can now use hindsight_retain, hindsight_recall, hindsight_reflect
result = user_proxy.initiate_chat(
assistant,
message="Remember that I prefer Python over JavaScript.",
)
```
That's it. The assistant can now store and retrieve memories across conversations.
## How It Works
The integration provides three AG2-compatible tool functions backed by Hindsight's API:
| Tool | Hindsight | What happens |
|------|-----------|--------------|
| `hindsight_retain(content)` | `retain(bank_id, content, ...)` | Content is stored. Hindsight extracts facts, entities, and relationships from the raw text. |
| `hindsight_recall(query)` | `recall(bank_id, query, ...)` | Hindsight runs semantic search, BM25, graph traversal, and reranking. Returns a numbered list of matching memories. |
| `hindsight_reflect(query)` | `reflect(bank_id, query, ...)` | Hindsight synthesizes a reasoned answer from all relevant memories, using the bank's disposition traits. |
Tools are plain Python functions with `Annotated` type hints. AG2 uses these hints to generate the tool schema that the LLM sees.
## Configuration
### Global Configuration
```python
from hindsight_ag2 import configure
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-key", # or set HINDSIGHT_API_KEY env var
budget="mid", # low / mid / high
max_tokens=4096,
tags=["source:ag2"], # default tags for retain
)
```
### Per-Tool Overrides
Constructor arguments override global configuration:
```python
from hindsight_ag2 import create_hindsight_tools
tools = create_hindsight_tools(
bank_id="my-bank",
hindsight_api_url="http://localhost:8888",
budget="high",
max_tokens=8192,
tags=["team:alpha"],
)
```
## GroupChat with Shared Memory
Multiple agents can share a single memory bank in a GroupChat:
```python
from autogen import AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager, LLMConfig
from hindsight_ag2 import register_hindsight_tools
llm_config = LLMConfig(api_type="openai", model="gpt-4o-mini")
with llm_config:
researcher = AssistantAgent(name="researcher", system_message="You research topics.")
writer = AssistantAgent(name="writer", system_message="You write content.")
executor = UserProxyAgent(name="executor", human_input_mode="NEVER")
# All agents share the same memory bank
for agent in [researcher, writer]:
register_hindsight_tools(agent, executor, bank_id="team-memory")
group_chat = GroupChat(agents=[researcher, writer, executor], messages=[])
manager = GroupChatManager(groupchat=group_chat)
```
## Manual Registration
For full control over how tools are registered:
```python
from hindsight_ag2 import create_hindsight_tools
tools = create_hindsight_tools(
bank_id="my-bank",
hindsight_api_url="http://localhost:8888",
)
for tool_fn in tools:
assistant.register_for_llm(description=tool_fn.__doc__)(tool_fn)
user_proxy.register_for_execution()(tool_fn)
```
## API Reference
### Configuration
| Function | Description |
|----------|-------------|
| `configure(...)` | Set global connection and default settings |
| `get_config()` | Get current configuration |
| `reset_config()` | Reset configuration to None |
### create_hindsight_tools
| Parameter | Default | Description |
|-----------|---------|-------------|
| `bank_id` | required | Hindsight memory bank ID |
| `client` | `None` | Pre-configured `Hindsight` client |
| `hindsight_api_url` | from config | Hindsight API URL |
| `api_key` | from config | API key |
| `budget` | `"mid"` | Recall/reflect budget (low/mid/high) |
| `max_tokens` | `4096` | Max tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode (any/all/any_strict/all_strict) |
| `retain_metadata` | `None` | Metadata dict for retain operations |
| `retain_document_id` | `None` | Document ID for retain (groups/upserts memories) |
| `recall_types` | `None` | Fact types to filter (world, experience, opinion, observation) |
| `recall_include_entities` | `False` | Include entity information in recall results |
| `reflect_context` | `None` | Additional context for reflect operations |
| `reflect_max_tokens` | `max_tokens` | Max tokens for reflect results |
| `reflect_response_schema` | `None` | JSON schema to constrain reflect output format |
| `reflect_tags` | `recall_tags` | Tags to filter memories used in reflect |
| `reflect_tags_match` | `recall_tags_match` | Tag matching for reflect |
| `include_retain` | `True` | Include the retain tool |
| `include_recall` | `True` | Include the recall tool |
| `include_reflect` | `True` | Include the reflect tool |
## Requirements
- Python >= 3.10
- ag2 >= 0.9.0
- A running Hindsight API server
@@ -1,188 +0,0 @@
---
sidebar_position: 9
title: "Agno Agent Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent memory to Agno agents using Hindsight's retain, recall, and reflect tools. Plug into Agno's native Toolkit pattern for long-term memory across sessions."
---
# Agno
Persistent memory tools for [Agno](https://github.com/agno-agi/agno) agents via Hindsight. Give your agents long-term memory with retain, recall, and reflect — using Agno's native Toolkit pattern.
## Features
- **Native Toolkit** - Extends Agno's `Toolkit` base class, just like `Mem0Tools`
- **Memory Instructions** - Pre-recall memories for injection into `Agent(instructions=[...])`
- **Three Memory Tools** - Retain (store), Recall (search), Reflect (synthesize) — include any combination
- **Flexible Bank Resolution** - Static bank ID, `RunContext.user_id`, or custom resolver
- **Simple Configuration** - Configure once globally, or pass a client directly
## Installation
```bash
pip install hindsight-agno
```
## Quick Start
```python
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from hindsight_agno import HindsightTools
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
tools=[HindsightTools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)],
)
agent.print_response("Remember that I prefer dark mode")
agent.print_response("What are my preferences?")
```
The agent now has three tools it can call:
- **`retain_memory`** — Store information to long-term memory
- **`recall_memory`** — Search long-term memory for relevant facts
- **`reflect_on_memory`** — Synthesize a reasoned answer from memories
## With Memory Instructions
Pre-recall relevant memories and inject them into the system prompt:
```python
from hindsight_agno import HindsightTools, memory_instructions
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
tools=[HindsightTools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)],
instructions=[memory_instructions(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)],
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = [HindsightTools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
enable_retain=True,
enable_recall=True,
enable_reflect=False, # Omit reflect
)]
```
## Bank Resolution
The bank ID is resolved in order:
1. **`bank_resolver`** — Custom callable `(RunContext) -> str`
2. **`bank_id`** — Static bank ID passed to constructor
3. **`run_context.user_id`** — Automatic per-user banks
```python
# Per-user banks from RunContext
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
tools=[HindsightTools(hindsight_api_url="http://localhost:8888")],
user_id="user-123", # Used as bank_id
)
# Custom resolver
def resolve_bank(ctx):
return f"team-{ctx.user_id}"
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini"),
tools=[HindsightTools(
bank_resolver=resolve_bank,
hindsight_api_url="http://localhost:8888",
)],
)
```
## Global Configuration
Instead of passing connection details to every toolkit, configure once:
```python
from hindsight_agno import configure, HindsightTools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create toolkit without passing connection details
tools = [HindsightTools(bank_id="user-123")]
```
## Configuration Reference
### `HindsightTools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static Hindsight memory bank ID |
| `bank_resolver` | `None` | Callable `(RunContext) -> str` for dynamic bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode |
| `enable_retain` | `True` | Include the retain (store) tool |
| `enable_recall` | `True` | Include the recall (search) tool |
| `enable_reflect` | `True` | Include the reflect (synthesize) tool |
### `memory_instructions()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `query` | `"relevant context about the user"` | Recall query for memory injection |
| `budget` | `"low"` | Recall budget level |
| `max_results` | `5` | Maximum memories to inject |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `prefix` | `"Relevant memories:\n"` | Text prepended before memory list |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
## Requirements
- Python >= 3.10
- agno
- hindsight-client >= 0.4.0
- A running Hindsight API server
@@ -1,101 +0,0 @@
---
sidebar_position: 4
title: "Vercel AI SDK Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to any Vercel AI SDK application with five ready-to-use Hindsight tools. Retain conversations, recall context, and reflect on past interactions — works with any model."
---
# Vercel AI SDK
The `@vectorize-io/hindsight-ai-sdk` package integrates [Hindsight](https://hindsight.vectorize.io) memory with the [Vercel AI SDK](https://ai-sdk.dev). It provides five ready-to-use tools for retaining, recalling, and reflecting on long-term memories.
[View Changelog →](/changelog/integrations/ai-sdk)
import CodeSnippet from '@site/src/components/CodeSnippet';
import aiSdkTs from '!!raw-loader!@site/examples/integrations/ai-sdk.ts';
## Installation
```bash
npm install @vectorize-io/hindsight-ai-sdk @vectorize-io/hindsight-client ai
```
## Setup
Create a Hindsight client and pass it to `createHindsightTools` along with a `bankId`. The `bankId` identifies the memory store for this session—typically a user ID.
<CodeSnippet code={aiSdkTs} section="setup" language="typescript" />
:::tip Per-request bank IDs
In multi-user applications, create `tools` inside your request handler so each request closes over the correct `bankId`. See the [Next.js example](#in-a-nextjs-route-handler) below.
:::
## Usage
### With `generateText`
<CodeSnippet code={aiSdkTs} section="generate-text" language="typescript" />
### With `streamText`
<CodeSnippet code={aiSdkTs} section="stream-text" language="typescript" />
### With `ToolLoopAgent`
<CodeSnippet code={aiSdkTs} section="tool-loop-agent" language="typescript" />
### In a Next.js Route Handler
<CodeSnippet code={aiSdkTs} section="next-api-route" language="typescript" />
---
## Tools Reference
Five tools are registered. The `bankId` is fixed at creation time—the agent cannot change it.
| Tool | What the agent provides | What the constructor controls |
|------|------------------------|-------------------------------|
| `retain` | `content`, `documentId`, `timestamp`, `context` | `async`, `tags`, `metadata` |
| `recall` | `query`, `queryTimestamp` | `budget`, `types`, `maxTokens`, `includeEntities`, `includeChunks` |
| `reflect` | `query`, `context` | `budget` |
| `getMentalModel` | `mentalModelId` | — |
| `getDocument` | `documentId` | — |
**Why this split?** Semantic inputs (what to remember, what to search for) belong to the agent. Infrastructure concerns (cost budget, tagging strategy, async mode) belong to the application.
---
## Constructor Options
All options except `client` and `bankId` are optional. Each tool's options are grouped under the tool name.
<CodeSnippet code={aiSdkTs} section="constructor-options" language="typescript" />
### `retain`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `async` | `boolean` | `false` | Fire-and-forget — do not wait for ingestion to complete |
| `tags` | `string[]` | — | Tags attached to every retained memory |
| `metadata` | `Record<string, string>` | — | Metadata attached to every retained memory |
| `description` | `string` | built-in | Override the tool description shown to the model |
### `recall`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Controls retrieval depth and latency |
| `types` | `('world' \| 'experience' \| 'observation')[]` | all | Restrict results to these fact types |
| `maxTokens` | `number` | API default | Cap the total tokens returned |
| `includeEntities` | `boolean` | `false` | Include entity observations in results |
| `includeChunks` | `boolean` | `false` | Include raw source chunks in results |
| `description` | `string` | built-in | Override the tool description shown to the model |
### `reflect`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Controls synthesis depth and latency |
| `maxTokens` | `number` | API default | Maximum tokens for the response |
| `description` | `string` | built-in | Override the tool description shown to the model |
@@ -1,167 +0,0 @@
---
sidebar_position: 5
title: "Vercel Chat SDK Persistent Memory with Hindsight | Integration"
description: "Give your Vercel Chat SDK bot persistent, per-user memory across Slack, Discord, Teams, and more. Single handler wrapper, no custom plumbing required."
---
# Vercel Chat SDK
We built `@vectorize-io/hindsight-chat` to give [Vercel Chat SDK](https://github.com/vercel/chat) bots persistent, per-user memory with a single handler wrapper. The integration works across Slack, Discord, Teams, Google Chat, GitHub, and Linear — no custom plumbing required.
[View Changelog →](/changelog/integrations/chat)
## Installation
```bash
npm install @vectorize-io/hindsight-chat
```
## Quick Start
```typescript
import { Chat } from 'chat';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { withHindsightChat } from '@vectorize-io/hindsight-chat';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
const chat = new Chat({ connectors: [/* your connectors */] });
const hindsight = new HindsightClient({ apiKey: process.env.HINDSIGHT_API_KEY });
chat.onNewMention(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId, // per-user memory
},
async (thread, message, ctx) => {
await thread.subscribe();
const result = await streamText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
// Stream the response
const chunks: string[] = [];
for await (const chunk of result.textStream) {
chunks.push(chunk);
}
const fullResponse = chunks.join('');
await thread.post(fullResponse);
// Store the conversation in memory
await ctx.retain(
`User: ${message.text}\nAssistant: ${fullResponse}`
);
}
)
);
```
## Configuration
### `withHindsightChat(options, handler)`
`withHindsightChat` wraps your existing Chat SDK handler and injects memory context automatically. It returns a standard handler `(thread, message) => Promise<void>` so it drops in without changing your handler signature.
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `HindsightClient` | *required* | Hindsight client instance |
| `bankId` | `string \| (msg) => string` | *required* | Memory bank ID or resolver function |
| `recall.enabled` | `boolean` | `true` | Auto-recall memories before handler |
| `recall.budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Processing budget for recall |
| `recall.maxTokens` | `number` | API default | Max tokens for recall results |
| `recall.types` | `FactType[]` | all | Filter to specific fact types |
| `recall.includeEntities` | `boolean` | `true` | Include entity observations |
| `retain.enabled` | `boolean` | `false` | Auto-retain inbound messages |
| `retain.async` | `boolean` | `true` | Fire-and-forget retain |
| `retain.tags` | `string[]` | | Tags for retained memories |
| `retain.metadata` | `Record<string, string>` | | Metadata for retained memories |
### Context (`ctx`)
We inject a third `ctx` argument into your handler that exposes the full Hindsight memory API scoped to the current user's bank:
| Property/Method | Description |
|----------------|-------------|
| `ctx.bankId` | Resolved bank ID |
| `ctx.memories` | Array of recalled memories |
| `ctx.entities` | Entity observations (or null) |
| `ctx.memoriesAsSystemPrompt(options?)` | Format memories for LLM system prompt |
| `ctx.retain(content, options?)` | Store content in memory |
| `ctx.recall(query, options?)` | Search memories |
| `ctx.reflect(query, options?)` | Reason over memories |
## Examples
### Subscribed Message Handler
```typescript
chat.onSubscribedMessage(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
recall: { budget: 'high', maxTokens: 1000 },
},
async (thread, message, ctx) => {
const result = await generateText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
await thread.post(result.text);
}
)
);
```
### Auto-Retain Inbound Messages
```typescript
chat.onNewMention(
withHindsightChat(
{
client: hindsight,
bankId: (msg) => msg.author.userId,
retain: { enabled: true, tags: ['slack', 'inbound'] },
},
async (thread, message, ctx) => {
// Inbound message is already being retained automatically
const result = await generateText({
model: openai('gpt-4o'),
system: ctx.memoriesAsSystemPrompt(),
messages: [{ role: 'user', content: message.text }],
});
await thread.post(result.text);
// Retain the assistant response separately
await ctx.retain(`Assistant: ${result.text}`, {
tags: ['slack', 'outbound'],
});
}
)
);
```
### Static Bank ID (Shared Memory)
```typescript
// All users share the same memory bank
chat.onNewMention(
withHindsightChat(
{ client: hindsight, bankId: 'shared-team-memory' },
async (thread, message, ctx) => {
// ...
}
)
);
```
## Error Handling
We designed the integration so that memory failures never break your bot. Auto-recall and auto-retain errors are caught internally, logged as warnings, and the handler continues with empty memories. Manual `ctx.retain()`, `ctx.recall()`, and `ctx.reflect()` calls propagate errors normally so you can handle them as needed.
@@ -1,216 +0,0 @@
---
sidebar_position: 5
title: "Claude Code Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to Claude Code with Hindsight. Automatically captures conversations and recalls relevant context across sessions using Claude Code's hook-based architecture."
---
# Claude Code
Biomimetic long-term memory for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context — a complete port of [`hindsight-openclaw`](./openclaw) adapted to Claude Code's hook-based plugin architecture.
[View Changelog →](/changelog/integrations/claude-code)
## Quick Start
```bash
# 1. Add the Hindsight marketplace and install the plugin
claude plugin marketplace add vectorize-io/hindsight
claude plugin install hindsight-memory
# 2. Configure your LLM provider for memory extraction
# Option A: OpenAI (auto-detected)
export OPENAI_API_KEY="sk-your-key"
# Option B: Anthropic (auto-detected)
export ANTHROPIC_API_KEY="your-key"
# Option C: No API key needed (uses Claude Code's own model — personal/local use only)
export HINDSIGHT_LLM_PROVIDER=claude-code
# Option D: Connect to an external Hindsight server instead of running locally
mkdir -p ~/.hindsight
echo '{"hindsightApiUrl": "https://your-hindsight-server.com"}' > ~/.hindsight/claude-code.json
# 3. Start Claude Code — the plugin activates automatically
claude
```
That's it! The plugin will automatically start capturing and recalling memories.
## Features
- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as context (invisible to the chat transcript, visible to Claude)
- **Auto-retain** — after every response (or every N turns), extracts and retains conversation content to Hindsight for long-term storage
- **Daemon management** — can auto-start/stop `hindsight-embed` locally or connect to an external Hindsight server
- **Dynamic bank IDs** — supports per-agent, per-project, or per-session memory isolation
- **Channel-agnostic** — works with Claude Code Channels (Telegram, Discord, Slack) or interactive sessions
- **Zero dependencies** — pure Python stdlib, no pip install required
## Architecture
The plugin uses all four Claude Code hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Health check — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — extract transcript, POST to Hindsight (async) |
| `session_end.py` | `SessionEnd` | Cleanup — stop auto-managed daemon if started |
## Connection Modes
### 1. External API (recommended for production)
Connect to a running Hindsight server (cloud or self-hosted). No local LLM needed — the server handles fact extraction.
```json
{
"hindsightApiUrl": "https://your-hindsight-server.com",
"hindsightApiToken": "your-token"
}
```
### 2. Local Daemon (auto-managed)
The plugin automatically starts and stops `hindsight-embed` via `uvx`. Requires an LLM provider API key for local fact extraction.
Set an LLM provider:
```bash
export OPENAI_API_KEY="sk-your-key"
# or
export ANTHROPIC_API_KEY="your-key"
# or
export HINDSIGHT_LLM_PROVIDER=claude-code # No API key needed
```
The model is selected automatically by the Hindsight API. To override, set `HINDSIGHT_LLM_MODEL`.
### 3. Existing Local Server
If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and set `apiPort` to match your server's port. The plugin will detect it automatically.
## Configuration
All settings live in `~/.hindsight/claude-code.json`. Every setting can also be overridden via environment variables. The plugin ships with sensible defaults — you only need to configure what you want to change.
**Loading order** (later entries win):
1. Built-in defaults (hardcoded in the plugin)
2. Plugin `settings.json` (ships with the plugin, at `CLAUDE_PLUGIN_ROOT/settings.json`)
3. User config (`~/.hindsight/claude-code.json` — recommended for your overrides)
4. Environment variables
---
### Connection & Daemon
These settings control how the plugin connects to the Hindsight API.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` (empty) | URL of an external Hindsight API server. When empty, the plugin uses a local daemon instead. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | Authentication token for the external API. Only needed when `hindsightApiUrl` is set. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port used by the local `hindsight-embed` daemon. Change this if you run multiple instances or have a port conflict. |
| `daemonIdleTimeout` | `HINDSIGHT_DAEMON_IDLE_TIMEOUT` | `0` | Seconds of inactivity before the local daemon shuts itself down. `0` means the daemon stays running until the session ends. |
| `embedVersion` | `HINDSIGHT_EMBED_VERSION` | `"latest"` | Which version of `hindsight-embed` to install via `uvx`. Pin to a specific version (e.g. `"0.5.2"`) for reproducibility. |
| `embedPackagePath` | `HINDSIGHT_EMBED_PACKAGE_PATH` | `null` | Local filesystem path to a `hindsight-embed` checkout. When set, the plugin runs from this path instead of installing via `uvx`. Useful for development. |
---
### LLM Provider (local daemon only)
These settings configure which LLM the local daemon uses for fact extraction. They are **ignored** when connecting to an external API (the server uses its own LLM configuration).
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `llmProvider` | `HINDSIGHT_LLM_PROVIDER` | auto-detect | Which LLM provider to use. Supported values: `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`. When omitted, the plugin auto-detects by checking for API key env vars in order: `OPENAI_API_KEY``ANTHROPIC_API_KEY``GEMINI_API_KEY``GROQ_API_KEY`. |
| `llmModel` | `HINDSIGHT_LLM_MODEL` | provider default | Override the default model for the chosen provider (e.g. `"gpt-4o"`, `"claude-sonnet-4-20250514"`). When omitted, the Hindsight API picks a sensible default for each provider. |
| `llmApiKeyEnv` | — | provider standard | Name of the environment variable that holds the API key. Normally auto-detected (e.g. `OPENAI_API_KEY` for the `openai` provider). Set this only if your key is in a non-standard env var. |
---
### Memory Bank
A **bank** is an isolated memory store — like a separate "brain." These settings control which bank the plugin reads from and writes to.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"claude_code"` | The bank ID to use when `dynamicBankId` is `false`. All sessions share this single bank. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | generic assistant prompt | A short description of the agent's identity and purpose. Sent to Hindsight when creating or updating the bank, and used during recall to contextualize results. |
| `retainMission` | — | extraction prompt | Instructions for the fact extraction LLM — tells it *what* to extract from conversations (e.g. "Extract technical decisions and user preferences"). |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, the plugin derives a unique bank ID from context fields (see `dynamicBankGranularity`), giving each combination its own isolated memory. |
| `dynamicBankGranularity` | — | `["agent", "project"]` | Which context fields to combine when building a dynamic bank ID. Available fields: `agent` (agent name), `project` (working directory), `session` (session ID), `channel` (channel ID), `user` (user ID). |
| `bankIdPrefix` | — | `""` | A string prepended to all bank IDs — both static and dynamic. Useful for namespacing (e.g. `"prod"` or `"staging"`). |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"claude-code"` | Name used for the `agent` field in dynamic bank ID derivation. |
---
### Auto-Recall
Auto-recall runs on every user prompt. It queries Hindsight for relevant memories and injects them into Claude's context as invisible `additionalContext` (the user doesn't see them in the chat transcript).
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. Set to `false` to disable memory retrieval entirely. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Controls how hard Hindsight searches for memories. `"low"` = fast, fewer strategies; `"mid"` = balanced; `"high"` = thorough, slower. Affects latency directly. |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Maximum number of tokens in the recalled memory block. Lower values reduce context usage but may truncate relevant memories. |
| `recallTypes` | — | `["world", "experience"]` | Which memory types to retrieve. `"world"` = general facts; `"experience"` = personal experiences; `"observation"` = raw observations. |
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
| `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
| `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
---
### Auto-Retain
Auto-retain runs after Claude responds. It extracts the conversation transcript and sends it to Hindsight for long-term storage and fact extraction.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. Set to `false` to disable memory storage entirely. |
| `retainMode` | `HINDSIGHT_RETAIN_MODE` | `"full-session"` | Retention strategy. `"full-session"` sends the full conversation transcript (with chunking). |
| `retainEveryNTurns` | — | `10` | How often to retain. `1` = every turn; `10` = every 10th turn. Higher values reduce API calls but delay memory capture. Values > 1 enable **chunked retention** with a sliding window. |
| `retainOverlapTurns` | — | `2` | When chunked retention fires, this many extra turns from the previous chunk are included for continuity. Total window size = `retainEveryNTurns + retainOverlapTurns`. |
| `retainRoles` | — | `["user", "assistant"]` | Which message roles to include in the retained transcript. |
| `retainToolCalls` | — | `true` | Whether to include tool calls (function invocations and results) in the retained transcript. Captures structured actions like file reads, searches, and code edits. |
| `retainTags` | — | `["{session_id}"]` | Tags attached to the retained document. Supports `{session_id}` placeholder which is replaced with the current session ID at runtime. |
| `retainMetadata` | — | `{}` | Arbitrary key-value metadata attached to the retained document. |
| `retainContext` | — | `"claude-code"` | A label attached to retained memories identifying their source. Useful when multiple integrations write to the same bank. |
---
### Debug
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr. All log lines are prefixed with `[Hindsight]`. Useful for diagnosing connection issues, recall/retain behavior, and bank ID derivation. |
## Claude Code Channels
With [Claude Code Channels](https://docs.anthropic.com/en/docs/claude-code), Claude Code can operate as a persistent background agent connected to Telegram, Discord, Slack, and other messaging platforms. This plugin gives Channel-based agents the same long-term memory that `hindsight-openclaw` provides for Openclaw agents.
For Channel agents, enable dynamic bank IDs for per-channel/per-user memory isolation:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "channel", "user"]
}
```
And set channel context via environment variables:
```bash
export HINDSIGHT_CHANNEL_ID="telegram-group-12345"
export HINDSIGHT_USER_ID="user-67890"
```
## Troubleshooting
**Plugin not activating**: Check Claude Code logs for `[Hindsight]` messages. Enable `"debug": true` in `~/.hindsight/claude-code.json`.
**Recall returning no memories**: Verify the Hindsight server is reachable (`curl http://localhost:9077/health`). Memories need at least one retain cycle before they're available.
**Daemon not starting**: Ensure an LLM API key is set (or use `HINDSIGHT_LLM_PROVIDER=claude-code`). Review daemon logs at `~/.hindsight/profiles/claude-code.log`.
**High latency on recall**: The recall hook has a 12-second timeout. Use `recallBudget: "low"` or reduce `recallMaxTokens` for faster responses.
@@ -1,184 +0,0 @@
---
sidebar_position: 6
title: "Codex CLI Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent memory to OpenAI Codex CLI with Hindsight. Three Python hook scripts automatically recall context before each prompt and retain conversations — no workflow changes required."
---
# Codex
[View Changelog →](/changelog/integrations/codex)
Persistent memory for [Codex CLI](https://github.com/openai/codex) using [Hindsight](https://vectorize.io/hindsight). Three Python hook scripts automatically recall relevant context before each prompt and retain conversations after each turn — no changes to your Codex workflow required.
## Quick Start
```bash
curl -fsSL https://hindsight.vectorize.io/get-codex | bash
```
The installer will guide you through choosing local or cloud mode and configuring your connection. Once installed, start a new Codex session — memory is live.
To uninstall:
```bash
curl -fsSL https://hindsight.vectorize.io/get-codex | bash -s -- --uninstall
```
## Features
- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as `additionalContext` (invisible to the transcript, visible to Codex)
- **Auto-retain** — after each Codex response, stores the conversation transcript to Hindsight for future recall
- **Dynamic bank IDs** — supports per-project memory isolation based on the working directory
- **Session-level upsert** — uses the session ID as the document ID so re-running the same session updates rather than duplicates stored content
- **Zero dependencies** — pure Python stdlib, no pip install required
## Architecture
The plugin uses three Codex hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Warm up — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — extract transcript, POST to Hindsight (async) |
On `UserPromptSubmit`, the hook reads the prompt, queries Hindsight for the most relevant memories, and outputs a `hookSpecificOutput.additionalContext` block. Codex prepends this to the conversation before sending it to the model:
```
<hindsight_memories>
Relevant memories from past conversations...
Current time - 2026-03-27 09:14
- Project uses FastAPI with asyncpg — not SQLAlchemy [world] (2026-03-26)
- Preferred testing framework: pytest with pytest-asyncio [experience] (2026-03-26)
</hindsight_memories>
```
On `Stop`, the hook reads the session transcript, strips previously injected memory tags (to prevent feedback loops), and POSTs the conversation to Hindsight asynchronously.
## Connection Modes
### 1. External API (recommended)
Connect to a running Hindsight server (cloud or self-hosted):
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
### 2. Local Daemon
Run `hindsight-embed` locally. The `session_start.py` hook will detect it on `apiPort` (default `9077`). The daemon is not auto-started by the Codex plugin — start it separately:
```bash
uvx hindsight-embed
```
Then leave `hindsightApiUrl` empty in your config and the plugin will connect to `http://localhost:9077`.
## Configuration
Settings are loaded from `~/.hindsight/codex.json`. Every setting can also be overridden via environment variable.
**Loading order** (later entries win):
1. Built-in defaults
2. Plugin `settings.json` (at `~/.hindsight/codex/settings.json`)
3. User config (`~/.hindsight/codex.json`)
4. Environment variables
---
### Connection
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of the Hindsight API server. Required. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | API token for authentication. Required for Hindsight Cloud. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon. |
---
### Memory Bank
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"codex"` | The bank to read from and write to. All sessions share this bank unless `dynamicBankId` is enabled. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | coding assistant prompt | Describes the agent's purpose. Sent when creating or updating the bank. |
| `retainMission` | — | extraction prompt | Instructions for Hindsight's fact extraction — what to extract from coding conversations. |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from `dynamicBankGranularity` fields — useful for per-project isolation. |
| `dynamicBankGranularity` | — | `["agent", "project"]` | Which fields to combine for dynamic bank IDs. `"project"` = working directory, `"agent"` = agent name. |
| `bankIdPrefix` | — | `""` | Prefix prepended to all bank IDs. |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"codex"` | Agent name used in dynamic bank ID derivation. |
---
### Auto-Recall
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search depth: `"low"` (fast), `"mid"` (balanced), `"high"` (thorough). |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Max tokens in the recalled memory block. |
| `recallTypes` | — | `["world", "experience"]` | Memory types to retrieve. |
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | Prior turns to include when building the recall query. `1` = latest prompt only. |
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Max characters in the query sent to Hindsight. |
| `recallRoles` | — | `["user", "assistant"]` | Which roles to include when building a multi-turn query. |
| `recallPromptPreamble` | — | built-in | Text placed above the recalled memories in the injected context block. |
---
### Auto-Retain
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. |
| `retainMode` | `HINDSIGHT_RETAIN_MODE` | `"full-session"` | `"full-session"` sends the full transcript per session (upserted by session ID). `"chunked"` sends sliding windows every N turns. |
| `retainEveryNTurns` | — | `10` | Retain fires every N turns. `1` = every turn. Higher values reduce API calls. |
| `retainOverlapTurns` | — | `2` | Extra turns included from the previous chunk (chunked mode only). |
| `retainRoles` | — | `["user", "assistant"]` | Which roles to include in the retained transcript. |
| `retainTags` | — | `["{session_id}"]` | Tags attached to the stored document. `{session_id}` is replaced at runtime. |
| `retainMetadata` | — | `{}` | Arbitrary key-value metadata attached to the stored document. |
| `retainContext` | — | `"codex"` | Label identifying the source integration. Useful when multiple integrations write to the same bank. |
---
### Debug
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr. All log lines are prefixed with `[Hindsight]`. |
## Per-Project Memory
To give each project its own isolated memory bank, enable dynamic bank IDs:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "project"]
}
```
With this config, running Codex in `~/projects/api` and `~/projects/frontend` stores and recalls memories separately. Bank IDs are derived from the working directory path.
## Troubleshooting
**Hooks not firing**: Check that `~/.codex/config.toml` contains `codex_hooks = true` under `[features]`. Re-run the installer to fix this automatically.
**No memories recalled**: Recall returns results only after something has been retained. Either complete one Codex session first, or seed your bank manually using the [cookbook example](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/codex-memory).
**Memory not being stored**: `retainEveryNTurns` defaults to `10` — retain only fires every 10 turns. While testing, add `"retainEveryNTurns": 1` to `~/.hindsight/codex.json`.
**Debug mode**: Add `"debug": true` to `~/.hindsight/codex.json` to see what Hindsight is doing on each turn:
```
[Hindsight] Recalling from bank 'codex', query length: 42
[Hindsight] Injecting 3 memories
[Hindsight] Retaining to bank 'codex', doc 'sess-abc123', 2 messages, 847 chars
```
**High latency on recall**: Use `"recallBudget": "low"` or reduce `recallMaxTokens` to speed up recall queries.
@@ -1,245 +0,0 @@
---
sidebar_position: 5
title: "CrewAI Persistent Memory with Hindsight | Integration Guide"
description: "Add long-term memory to your CrewAI agent crews. Hindsight provides fact extraction, entity tracking, and temporal awareness — persisted automatically across all crew runs."
---
# CrewAI
Persistent memory for AI agent crews via [CrewAI](https://github.com/crewAIInc/crewAI). Give your crews long-term memory with fact extraction, entity tracking, and temporal awareness.
[View Changelog →](/changelog/integrations/crewai)
## Features
- **Drop-in Storage Backend** - Implements CrewAI's `Storage` interface for `ExternalMemory`
- **Automatic Memory Flow** - CrewAI automatically stores task outputs and retrieves relevant memories
- **Per-Agent Banks** - Optionally give each agent its own isolated memory bank
- **Reflect Tool** - Agents can explicitly reason over memories with disposition-aware synthesis
- **Simple Configuration** - Configure once, use everywhere
## Installation
```bash
pip install hindsight-crewai
```
## Quick Start
```python
from hindsight_crewai import configure, HindsightStorage
from crewai.memory.external.external_memory import ExternalMemory
from crewai import Agent, Crew, Task
configure(hindsight_api_url="http://localhost:8888")
crew = Crew(
agents=[Agent(role="Researcher", goal="Find information", backstory="...")],
tasks=[Task(description="Research AI trends", expected_output="Report")],
external_memory=ExternalMemory(
storage=HindsightStorage(bank_id="my-crew")
),
)
crew.kickoff()
```
That's it. CrewAI will automatically:
- **Query memories** at the start of each task
- **Store task outputs** to Hindsight after each task completes
Memories persist across crew runs, so your crew learns over time.
## How It Works
The integration maps CrewAI's 3-method `Storage` interface to Hindsight's API:
| CrewAI | Hindsight | What happens |
|--------|-----------|--------------|
| `save(value, metadata, agent)` | `retain(bank_id, content, ...)` | Task output is stored. Hindsight extracts facts, entities, and relationships from the raw text. |
| `search(query, limit)` | `recall(bank_id, query, ...)` | CrewAI constructs a query from the task description. Hindsight runs semantic search, BM25, graph traversal, and reranking. |
| `reset()` | `delete_bank(bank_id)` | Wipes the bank and optionally recreates it with its original mission. |
CrewAI calls `search()` automatically at the start of each task and `save()` after each task completes.
## Configuration Options
```python
from hindsight_crewai import configure
configure(
hindsight_api_url="http://localhost:8888", # Hindsight API URL
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: "low", "mid", "high"
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match: any/all/any_strict/all_strict
verbose=True, # Enable logging
)
```
### Per-Storage Overrides
Constructor arguments override global configuration:
```python
storage = HindsightStorage(
bank_id="my-crew",
budget="high",
max_tokens=8192,
tags=["team:alpha"],
)
```
## Bank Missions
Set a mission to guide how Hindsight processes and organizes memories:
```python
storage = HindsightStorage(
bank_id="my-crew",
mission="Track software architecture decisions, technical debt, and team preferences.",
)
```
## Per-Agent Memory Banks
Give each agent its own isolated memory bank:
```python
storage = HindsightStorage(
bank_id="my-crew",
per_agent_banks=True,
# Researcher -> "my-crew-researcher"
# Writer -> "my-crew-writer"
)
```
Or use a custom bank resolver for full control:
```python
storage = HindsightStorage(
bank_id="my-crew",
bank_resolver=lambda base, agent: f"{base}-{agent.lower()}" if agent else base,
)
```
:::info
When `per_agent_banks=True`, the automatic `search()` at task start queries the base bank (shared context), since CrewAI's `search()` method does not receive the agent parameter. For per-agent search isolation, create separate `HindsightStorage` instances per agent.
:::
## Reflect Tool
CrewAI's storage interface only supports save/search/reset. To give agents access to Hindsight's `reflect` (disposition-aware memory synthesis), add it as a tool:
```python
from hindsight_crewai import HindsightReflectTool
reflect_tool = HindsightReflectTool(
bank_id="my-crew",
budget="mid",
reflect_context="You are helping a software team track decisions.",
)
agent = Agent(
role="Analyst",
goal="Analyze project history",
backstory="...",
tools=[reflect_tool],
)
```
When the agent calls this tool, it gets a synthesized, contextual answer based on all relevant memories rather than raw fact snippets.
## Full Example
A research crew that remembers findings across runs:
```python
from hindsight_crewai import configure, HindsightStorage, HindsightReflectTool
from crewai.memory.external.external_memory import ExternalMemory
from crewai import Agent, Crew, Task
configure(hindsight_api_url="http://localhost:8888")
storage = HindsightStorage(
bank_id="research-crew",
mission="Track technology research findings and comparisons.",
)
reflect_tool = HindsightReflectTool(bank_id="research-crew", budget="mid")
researcher = Agent(
role="Researcher",
goal="Research topics, building on prior knowledge.",
backstory="Before starting, use hindsight_reflect to check what you already know.",
tools=[reflect_tool],
)
writer = Agent(
role="Writer",
goal="Write summaries incorporating prior findings.",
backstory="Use hindsight_reflect to recall prior research.",
tools=[reflect_tool],
)
crew = Crew(
agents=[researcher, writer],
tasks=[
Task(description="Research the benefits of Rust", expected_output="Analysis", agent=researcher),
Task(description="Write an executive summary", expected_output="Summary", agent=writer),
],
external_memory=ExternalMemory(storage=storage),
)
# Run 1: researches Rust, stores findings
crew.kickoff()
# Run 2: recalls Rust research when comparing with Go
crew.tasks[0].description = "Compare Rust with Go"
crew.kickoff()
```
## API Reference
### Configuration
| Function | Description |
|----------|-------------|
| `configure(...)` | Set global connection and default settings |
| `get_config()` | Get current configuration |
| `reset_config()` | Reset configuration to None |
### Storage
| Parameter | Default | Description |
|-----------|---------|-------------|
| `bank_id` | required | Hindsight memory bank ID |
| `hindsight_api_url` | from config | Override API URL |
| `api_key` | from config | Override API key |
| `budget` | `"mid"` | Recall budget (low/mid/high) |
| `max_tokens` | `4096` | Max tokens for recall results |
| `tags` | `None` | Tags applied when storing |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode |
| `per_agent_banks` | `False` | Give each agent its own bank |
| `bank_resolver` | `None` | Custom `(bank_id, agent) -> bank_id` |
| `mission` | `None` | Bank mission for memory organization |
| `verbose` | `False` | Enable verbose logging |
### Reflect Tool
| Parameter | Default | Description |
|-----------|---------|-------------|
| `bank_id` | required | Hindsight memory bank ID |
| `budget` | `"mid"` | Reflect budget (low/mid/high) |
| `reflect_context` | `None` | Additional context for reasoning |
| `hindsight_api_url` | from config | Override API URL |
| `api_key` | from config | Override API key |
## Requirements
- Python >= 3.10
- crewai >= 0.86.0
- A running Hindsight API server
@@ -1,319 +0,0 @@
---
sidebar_position: 7
title: "LangGraph & LangChain Persistent Memory with Hindsight"
description: "Add long-term memory to LangGraph and LangChain agents with Hindsight. Three integration patterns — tools, nodes, and BaseStore adapter — for persistent memory across agent runs."
---
# LangGraph / LangChain
Persistent long-term memory for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com/) agents via Hindsight. Three integration patterns at different abstraction levels — the tools pattern works with both LangChain and LangGraph, while nodes and the BaseStore adapter are LangGraph-specific.
[View Changelog →](/changelog/integrations/langgraph)
## Features
- **Memory Tools** — retain, recall, and reflect as LangChain `@tool` functions compatible with `bind_tools()` and `ToolNode`. Works with **both LangChain and LangGraph** — no LangGraph dependency required for this pattern.
- **Graph Nodes** *(LangGraph)* — Pre-built nodes that auto-inject memories before LLM calls and auto-store after responses
- **BaseStore Adapter** *(LangGraph)* — Drop-in `BaseStore` implementation backed by Hindsight, for LangGraph's native memory patterns
- **Dynamic Banks** — Resolve bank IDs per-request from `RunnableConfig` for per-user memory
- **Async-Native** — Uses `aretain`, `arecall`, `areflect` directly — no thread-pool workarounds
## Installation
```bash
pip install hindsight-langgraph
```
## Quick Start: Tools (LangChain & LangGraph)
The tools pattern creates standard LangChain `@tool` functions that work with any LangChain-compatible model via `bind_tools()`. You can use them with a LangGraph agent or with plain LangChain — no LangGraph required.
**With LangGraph (recommended):**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
agent = create_react_agent(ChatOpenAI(model="gpt-4o"), tools=tools)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]}
)
```
**With plain LangChain:**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
response = await model.ainvoke("Remember that I prefer dark mode")
```
When using plain LangChain, you handle the tool execution loop yourself — call the model, check for `tool_calls`, execute them, and feed results back. LangGraph automates this loop for you.
The agent gets three tools it can call:
- **`hindsight_retain`** — Store information to long-term memory
- **`hindsight_recall`** — Search long-term memory for relevant facts
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
## Quick Start: Memory Nodes (LangGraph)
Add recall and retain nodes to your graph for automatic memory injection and storage.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_recall_node, create_retain_node
from langgraph.graph import StateGraph, MessagesState, START, END
client = Hindsight(base_url="http://localhost:8888")
recall = create_recall_node(client=client, bank_id="user-123")
retain = create_retain_node(client=client, bank_id="user-123")
builder = StateGraph(MessagesState)
builder.add_node("recall", recall)
builder.add_node("agent", agent_node) # your LLM node
builder.add_node("retain", retain)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
builder.add_edge("retain", END)
graph = builder.compile()
```
The recall node extracts the latest user message, searches Hindsight, and injects matching memories as a `SystemMessage`. The retain node stores human messages (optionally AI messages too) after the response.
## Quick Start: BaseStore (LangGraph)
Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import HindsightStore
client = Hindsight(base_url="http://localhost:8888")
store = HindsightStore(client=client)
graph = builder.compile(checkpointer=checkpointer, store=store)
# Store and search via the store API
await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"})
results = await store.asearch(("user", "123", "prefs"), query="theme preference")
```
Namespace tuples are mapped to Hindsight bank IDs with `.` as separator (e.g., `("user", "123")` becomes bank `user.123`). Banks are auto-created on first access.
## Dynamic Bank IDs
Both nodes and the store support per-user bank resolution from `RunnableConfig`:
```python
recall = create_recall_node(client=client, bank_id_from_config="user_id")
retain = create_retain_node(client=client, bank_id_from_config="user_id")
# Bank ID resolved at runtime from config
result = await graph.ainvoke(
{"messages": [{"role": "user", "content": "hello"}]},
config={"configurable": {"user_id": "user-456"}},
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=True,
include_recall=True,
include_reflect=False, # Omit reflect
)
```
## Global Configuration
Instead of passing a client to every call, configure once:
```python
from hindsight_langgraph import configure, create_hindsight_tools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create tools without passing client — uses global config
tools = create_hindsight_tools(bank_id="user-123")
```
## Retain Node Options
```python
retain = create_retain_node(
client=client,
bank_id="user-123",
retain_human=True, # Store human messages (default: True)
retain_ai=False, # Store AI responses (default: False)
tags=["source:chat"], # Tags applied to stored memories
)
```
## Recall Node Options
```python
recall = create_recall_node(
client=client,
bank_id="user-123",
budget="low", # Recall budget: low/mid/high
max_results=10, # Max memories injected
max_tokens=4096, # Max tokens for recall
tags=["scope:user"], # Filter by tags
tags_match="all", # Tag match mode
)
```
### Using `output_key` for Prompt Control
By default, the recall node appends a `SystemMessage` to `messages`. Use `output_key` to write memory text to a custom state field instead, giving you full control over prompt ordering:
```python
from typing import Optional
from langgraph.graph import MessagesState
class AgentState(MessagesState):
memory_context: Optional[str] = None
recall = create_recall_node(
client=client,
bank_id="user-123",
output_key="memory_context",
)
# In your agent node, read state["memory_context"] and prepend it
# to the system prompt before calling the model.
```
## Limitations and Notes
### HindsightStore
- **Async-only.** All sync methods (`batch`, `get`, `put`, `delete`, `search`, `list_namespaces`) raise `NotImplementedError`. Use the async variants (`abatch`, `aget`, `aput`, `adelete`, `asearch`, `alist_namespaces`) instead.
- **`get()` relies on recall.** There is no direct key lookup — the key is used as a recall query and only exact `document_id` matches are returned. Items that do not rank in the top recall results may appear missing.
- **`list_namespaces` is session-scoped.** It only tracks namespaces that have been written to via `aput()` during the current process. After a restart, `list_namespaces` returns empty even though data still exists in Hindsight.
- **`delete` is a no-op.** Calling `adelete()` logs a debug message but does not remove data from Hindsight. Hindsight's memory model is append-oriented; fact superseding is handled automatically during retain.
### Memory Nodes
- **SystemMessage ordering.** The recall node adds a `SystemMessage` with recalled memories. Because `MessagesState` uses `add_messages` (which appends), this message appears after existing messages rather than at position 0. The message has a stable ID (`hindsight_memory_context`) so it is updated rather than duplicated across invocations. If your LLM provider requires system messages first, sort or filter messages in your agent node before passing them to the model.
### Error Handling
- **Tools** raise `HindsightError` on failure, which surfaces to the agent as a tool error.
- **Nodes** silently log errors and return empty messages, so a Hindsight outage does not crash your graph.
## API Reference
### `create_hindsight_tools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode (any/all/any\_strict/all\_strict) |
| `retain_metadata` | `None` | Default metadata dict for retain operations |
| `retain_document_id` | `None` | Default document\_id for retain (groups/upserts memories) |
| `recall_types` | `None` | Fact types to filter (world, experience, opinion, observation) |
| `recall_include_entities` | `False` | Include entity information in recall results |
| `reflect_context` | `None` | Additional context for reflect operations |
| `reflect_max_tokens` | `None` | Max tokens for reflect results (defaults to `max_tokens`) |
| `reflect_response_schema` | `None` | JSON schema to constrain reflect output format |
| `reflect_tags` | `None` | Tags to filter memories used in reflect (defaults to `recall_tags`) |
| `reflect_tags_match` | `None` | Tag matching for reflect (defaults to `recall_tags_match`) |
| `include_retain` | `True` | Include the retain (store) tool |
| `include_recall` | `True` | Include the recall (search) tool |
| `include_reflect` | `True` | Include the reflect (synthesize) tool |
### `create_recall_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall budget level |
| `max_tokens` | `4096` | Max tokens for recall results |
| `max_results` | `10` | Max memories to inject |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `output_key` | `None` | If set, write memory text to this state key instead of appending a SystemMessage to `messages` |
### `create_retain_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to stored memories |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `retain_human` | `True` | Store human messages |
| `retain_ai` | `False` | Store AI responses |
### `HindsightStore()`
| Parameter | Default | Description |
|---|---|---|
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to all retain operations |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
## Requirements
- Python >= 3.10
- langchain-core >= 0.3.0
- hindsight-client >= 0.4.0
- langgraph >= 0.3.0 *(only for nodes and store patterns — install with `pip install hindsight-langgraph[langgraph]`)*
@@ -1,349 +0,0 @@
---
sidebar_position: 1
title: "LiteLLM Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent memory to any LLM application via LiteLLM and Hindsight. Universal integration — works with any model or provider with just a few lines of code."
---
# LiteLLM
Universal LLM memory integration via [LiteLLM](https://github.com/BerriAI/litellm). Add persistent memory to any LLM application with just a few lines of code.
[View Changelog →](/changelog/integrations/litellm)
## Features
- **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more)
- **Simple Integration** - Just configure, enable, and use `hindsight_litellm.completion()`
- **Automatic Memory Injection** - Relevant memories are injected into prompts before LLM calls
- **Automatic Conversation Storage** - Conversations are stored to Hindsight for future recall
- **Two Memory Modes** - Choose between `reflect` (synthesized context) or `recall` (raw memory retrieval)
- **Direct Memory APIs** - Query, synthesize, and store memories manually
- **Native Client Wrappers** - Alternative wrappers for OpenAI and Anthropic SDKs
## Installation
```bash
pip install hindsight-litellm
```
## Quick Start
```python
import hindsight_litellm
# Configure and enable memory integration
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="my-agent",
)
hindsight_litellm.enable()
# Use the convenience wrapper - memory is automatically injected and stored
response = hindsight_litellm.completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What did we discuss about AI?"}]
)
```
## How It Works
When you call `completion()`, the following happens automatically:
1. **Memory Retrieval** - Hindsight is queried for relevant memories based on the conversation
2. **Prompt Injection** - Memories are injected into the system message
3. **LLM Call** - The enriched prompt is sent to the LLM
4. **Conversation Storage** - The conversation is stored to Hindsight for future recall
5. **Response Returned** - You receive the response as normal
## Configuration Options
```python
hindsight_litellm.configure(
# Required
hindsight_api_url="http://localhost:8888", # Hindsight API server URL
bank_id="my-agent", # Memory bank ID
api_key="your-api-key", # Optional API key for authentication
# Optional - Memory behavior
store_conversations=True, # Store conversations after LLM calls
inject_memories=True, # Inject relevant memories into prompts
use_reflect=False, # Use reflect API (synthesized) vs recall (raw memories)
reflect_include_facts=False, # Include source facts with reflect responses
max_memories=None, # Maximum memories to inject (None = unlimited)
max_memory_tokens=4096, # Maximum tokens for memory context
recall_budget="mid", # Recall budget: "low", "mid", "high"
fact_types=["world", "agent"], # Filter fact types to inject
# Optional - Bank Configuration
bank_name="My Agent", # Human-readable display name for the memory bank
mission="This agent...", # Instructions guiding what Hindsight should remember
# Optional - Advanced
injection_mode="system_message", # or "prepend_user"
excluded_models=["gpt-3.5*"], # Exclude certain models
verbose=True, # Enable verbose logging and debug info
)
```
### Bank Configuration
The `mission` and `bank_name` parameters configure the memory bank itself. When provided, `configure()` will automatically create or update the bank with these settings.
```python
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="support-router",
bank_name="Customer Support Router",
mission="""You're a customer support router - keep track of which types of issues
should go to which teams (billing, technical, sales), customer preferences for
communication channels, and past issue resolutions.""",
)
```
### Memory Modes: Reflect vs Recall
- **Recall mode** (`use_reflect=False`, default): Retrieves raw memory facts and injects them as a numbered list. Best when you need precise, individual memories.
- **Reflect mode** (`use_reflect=True`): Synthesizes memories into a coherent context paragraph. Best for natural, conversational memory context.
```python
# Recall mode - raw memories
hindsight_litellm.configure(
bank_id="my-agent",
use_reflect=False, # Default
)
# Injects: "1. [WORLD] User prefers Python\n2. [MENTAL MODEL] User prefers simple code..."
# Reflect mode - synthesized context
hindsight_litellm.configure(
bank_id="my-agent",
use_reflect=True,
)
# Injects: "Based on previous conversations, the user is a Python developer who..."
```
## Multi-Provider Support
Works with any LiteLLM-supported provider:
```python
import hindsight_litellm
hindsight_litellm.configure(
hindsight_api_url="http://localhost:8888",
bank_id="my-agent",
)
hindsight_litellm.enable()
# OpenAI
hindsight_litellm.completion(model="gpt-4o", messages=[...])
# Anthropic
hindsight_litellm.completion(model="claude-3-5-sonnet-20241022", messages=[...])
# Groq
hindsight_litellm.completion(model="groq/llama-3.1-70b-versatile", messages=[...])
# Azure OpenAI
hindsight_litellm.completion(model="azure/gpt-4", messages=[...])
# AWS Bedrock
hindsight_litellm.completion(model="bedrock/anthropic.claude-3", messages=[...])
# Google Vertex AI
hindsight_litellm.completion(model="vertex_ai/gemini-pro", messages=[...])
```
## Direct Memory APIs
### Recall - Query raw memories
```python
from hindsight_litellm import configure, recall
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
memories = recall("what projects am I working on?", budget="mid")
for m in memories:
print(f"- [{m.fact_type}] {m.text}")
```
### Reflect - Get synthesized context
```python
from hindsight_litellm import configure, reflect
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
result = reflect("what do you know about the user's preferences?")
print(result.text)
```
### Retain - Store memories
```python
from hindsight_litellm import configure, retain
configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
result = retain(
content="User mentioned they're working on a machine learning project",
context="Discussion about current projects",
)
```
### Async APIs
```python
from hindsight_litellm import arecall, areflect, aretain
# Async versions of all memory APIs
memories = await arecall("what do you know about me?")
context = await areflect("summarize user preferences")
result = await aretain(content="New information to remember")
```
## Native Client Wrappers
Alternative to LiteLLM callbacks for direct SDK integration.
### OpenAI Wrapper
```python
from openai import OpenAI
from hindsight_litellm import wrap_openai
client = OpenAI()
wrapped = wrap_openai(
client,
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
)
response = wrapped.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What do you know about me?"}]
)
```
### Anthropic Wrapper
```python
from anthropic import Anthropic
from hindsight_litellm import wrap_anthropic
client = Anthropic()
wrapped = wrap_anthropic(
client,
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
)
response = wrapped.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}]
)
```
## Debug Mode
When `verbose=True`, you can inspect exactly what memories are being injected:
```python
from hindsight_litellm import configure, enable, completion, get_last_injection_debug
configure(
bank_id="my-agent",
hindsight_api_url="http://localhost:8888",
verbose=True,
)
enable()
response = completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What's my favorite color?"}]
)
# Inspect what was injected
debug = get_last_injection_debug()
if debug:
print(f"Mode: {debug.mode}") # "reflect" or "recall"
print(f"Injected: {debug.injected}") # True/False
print(f"Results: {debug.results_count}")
print(f"Memory context:\n{debug.memory_context}")
```
## Context Manager
```python
from hindsight_litellm import hindsight_memory
import litellm
with hindsight_memory(bank_id="user-123"):
response = litellm.completion(model="gpt-4", messages=[...])
# Memory integration automatically disabled after context
```
## Disabling and Cleanup
```python
from hindsight_litellm import disable, cleanup
# Temporarily disable memory integration
disable()
# Clean up all resources (call when shutting down)
cleanup()
```
## API Reference
### Main Functions
| Function | Description |
|----------|-------------|
| `configure(...)` | Configure global Hindsight settings |
| `enable()` | Enable memory integration with LiteLLM |
| `disable()` | Disable memory integration |
| `is_enabled()` | Check if memory integration is enabled |
| `cleanup()` | Clean up all resources |
### Configuration Functions
| Function | Description |
|----------|-------------|
| `get_config()` | Get current configuration |
| `is_configured()` | Check if Hindsight is configured |
| `reset_config()` | Reset configuration to defaults |
### Memory Functions
| Function | Description |
|----------|-------------|
| `recall(query, ...)` | Synchronously query raw memories |
| `arecall(query, ...)` | Asynchronously query raw memories |
| `reflect(query, ...)` | Synchronously get synthesized memory context |
| `areflect(query, ...)` | Asynchronously get synthesized memory context |
| `retain(content, ...)` | Synchronously store a memory |
| `aretain(content, ...)` | Asynchronously store a memory |
### Debug Functions
| Function | Description |
|----------|-------------|
| `get_last_injection_debug()` | Get debug info from last memory injection |
| `clear_injection_debug()` | Clear stored debug info |
### Client Wrappers
| Function | Description |
|----------|-------------|
| `wrap_openai(client, ...)` | Wrap OpenAI client with memory |
| `wrap_anthropic(client, ...)` | Wrap Anthropic client with memory |
## Requirements
- Python >= 3.10
- litellm >= 1.40.0
- A running Hindsight API server
@@ -1,251 +0,0 @@
---
sidebar_position: 8
title: "LlamaIndex Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to LlamaIndex agents with Hindsight. Supports agent-driven tools (HindsightToolSpec) and automatic memory via the BaseMemory interface."
---
# LlamaIndex
Persistent long-term memory for [LlamaIndex](https://docs.llamaindex.ai/) agents via Hindsight. The `hindsight-llamaindex` package provides two complementary patterns:
- **`HindsightToolSpec`** — Agent-driven memory tools (retain/recall/reflect)
- **`HindsightMemory`** — Automatic memory via LlamaIndex's `BaseMemory` interface
## Installation
```bash
pip install hindsight-llamaindex
```
---
## Automatic Memory (BaseMemory)
The simplest way to add Hindsight memory to a LlamaIndex agent. Messages are automatically stored on each turn, and relevant memories are recalled and injected as context.
```python
import asyncio
from hindsight_client import Hindsight
from hindsight_llamaindex import HindsightMemory
from llama_index.core.agent import ReActAgent
from llama_index.llms.openai import OpenAI
async def main():
client = Hindsight(base_url="http://localhost:8888")
memory = HindsightMemory.from_client(
client=client,
bank_id="user-123",
mission="Track user preferences and project context",
)
agent = ReActAgent(tools=[], llm=OpenAI(model="gpt-4o"))
response = await agent.run("Remember that I prefer dark mode", memory=memory)
print(response)
asyncio.run(main())
```
### How It Works
| Event | What Happens |
|-------|-------------|
| Agent receives input | `aget(input)` recalls relevant memories from Hindsight, prepends as system message |
| Agent produces output | `aput(message)` retains the message to Hindsight for future recall |
| New session starts | Previous memories are available via recall; local chat buffer starts empty |
### `HindsightMemory.from_client()`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `client` | `Hindsight` | *required* | Hindsight client instance |
| `bank_id` | `str` | *required* | Memory bank ID |
| `mission` | `str` | `None` | Bank mission — auto-creates bank on first use |
| `context` | `str` | `"llamaindex"` | Source label for retain operations |
| `budget` | `str` | `"mid"` | Recall budget level |
| `max_tokens` | `int` | `4096` | Max recall tokens |
| `tags` | `list[str]` | `None` | Tags for retain operations |
| `recall_tags` | `list[str]` | `None` | Tags to filter recall |
| `recall_tags_match` | `str` | `"any"` | Tag matching mode |
| `system_prompt` | `str` | *(built-in)* | Template for memory system message. Must contain `{memories}` |
| `chat_history_limit` | `int` | `100` | Max messages in local buffer |
Also available: `HindsightMemory.from_url(hindsight_api_url, bank_id, ...)` for creating without a pre-built client.
---
## Agent-Driven Tools (BaseToolSpec)
For explicit control, expose retain/recall/reflect as tools the agent can choose to call.
### Quick Start: Tool Spec
```python
import asyncio
from hindsight_client import Hindsight
from hindsight_llamaindex import HindsightToolSpec
from llama_index.llms.openai import OpenAI
from llama_index.core.agent import ReActAgent
async def main():
client = Hindsight(base_url="http://localhost:8888")
spec = HindsightToolSpec(
client=client,
bank_id="user-123",
mission="Track user preferences",
)
tools = spec.to_tool_list()
agent = ReActAgent(tools=tools, llm=OpenAI(model="gpt-4o"))
response = await agent.run("Remember that I prefer dark mode")
print(response)
asyncio.run(main())
```
### Quick Start: Factory Function
```python
from hindsight_llamaindex import create_hindsight_tools
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
mission="Track user preferences",
)
```
### Selecting Tools
```python
# Via to_tool_list()
tools = spec.to_tool_list(spec_functions=["recall_memory", "reflect_on_memory"])
# Via factory flags
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=True,
include_recall=True,
include_reflect=False,
)
```
### Configuration
Set defaults via `configure()`, override per-call:
```python
from hindsight_llamaindex import configure
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # or set HINDSIGHT_API_KEY env var
budget="mid",
tags=["source:llamaindex"],
context="my-app",
mission="Track user preferences",
)
# Now create tools without passing client/url
tools = create_hindsight_tools(bank_id="user-123")
```
### `HindsightToolSpec()`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `bank_id` | `str` | *required* | Hindsight memory bank to operate on |
| `client` | `Hindsight` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `str` | `None` | API URL (used if no client provided) |
| `api_key` | `str` | `None` | API key (used if no client provided) |
| `budget` | `str` | `None``"mid"` | Recall/reflect budget: `low`, `mid`, `high` |
| `max_tokens` | `int` | `None``4096` | Max tokens for recall results |
| `tags` | `list[str]` | `None` | Tags applied when storing memories |
| `recall_tags` | `list[str]` | `None` | Tags to filter recall results |
| `recall_tags_match` | `str` | `None``"any"` | Tag matching: `any`, `all`, `any_strict`, `all_strict` |
| `retain_metadata` | `dict[str, str]` | `None` | Default metadata for retain operations |
| `retain_document_id` | `str` | `None` | Document ID for retain. Auto-generates `{session}-{timestamp}` if not set |
| `retain_context` | `str` | `"llamaindex"` | Source label for retain operations |
| `recall_types` | `list[str]` | `None` | Fact types: `world`, `experience`, `opinion`, `observation` |
| `recall_include_entities` | `bool` | `False` | Include entity info in recall results |
| `reflect_context` | `str` | `None` | Additional context for reflect |
| `reflect_max_tokens` | `int` | `None` | Max tokens for reflect (defaults to `max_tokens`) |
| `reflect_response_schema` | `dict` | `None` | JSON schema to constrain reflect output |
| `reflect_tags` | `list[str]` | `None` | Tags for reflect (defaults to `recall_tags`) |
| `reflect_tags_match` | `str` | `None` | Tag matching for reflect (defaults to `recall_tags_match`) |
| `mission` | `str` | `None` | Bank mission — auto-creates bank on first use |
---
## Production Patterns
### Bank Mission
Set a mission to give the memory engine context for fact extraction:
```python
# Tools
spec = HindsightToolSpec(
client=client,
bank_id="user-123",
mission="Track user coding preferences, project context, and technical decisions",
)
# Memory
memory = HindsightMemory.from_client(
client=client,
bank_id="user-123",
mission="Track user coding preferences, project context, and technical decisions",
)
```
The bank is created automatically on first use. If it already exists, creation is silently skipped.
### Memory Scoping with Tags
```python
spec = HindsightToolSpec(
client=client,
bank_id="user-123",
tags=["source:chat", "session:abc"], # applied to all retains
recall_tags=["source:chat"], # filter recalls to chat memories
recall_tags_match="any",
)
```
### Error Handling
Both patterns handle errors gracefully — operations are logged and return friendly messages instead of raising exceptions. Agents continue functioning even if memory is unavailable.
### Combining Tools + Memory
Use both patterns together for maximum flexibility:
```python
from hindsight_llamaindex import create_hindsight_tools, HindsightMemory
# Automatic memory for context enrichment
memory = HindsightMemory.from_client(client=client, bank_id="user-123")
# Explicit tools for agent-driven reflect
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=False, # memory handles retain automatically
include_recall=False, # memory handles recall automatically
include_reflect=True, # agent can still explicitly reflect
)
agent = ReActAgent(tools=tools, llm=llm)
# Pass memory to run()
response = await agent.run("What should I prioritize?", memory=memory)
```
## Requirements
- Python 3.10+
- `llama-index-core >= 0.11.0`
- `hindsight-client >= 0.4.0`
@@ -1,176 +0,0 @@
---
sidebar_position: 2
title: "Hindsight Local MCP Server | Persistent Memory for Claude"
description: "Run Hindsight as a local MCP server with embedded PostgreSQL — no external setup required. Ideal for Claude Code and Claude Desktop for long-term memory across conversations."
---
# Local MCP Server
Hindsight provides a local MCP server that runs entirely on your machine with an embedded PostgreSQL database. No external server or database setup required.
This is ideal for:
- **Personal use with Claude Code / Claude Desktop** — Give Claude long-term memory across conversations
- **Development and testing** — Quick setup without infrastructure
- **Privacy-focused setups** — All data stays on your machine
## How It Works
Running `hindsight-local-mcp` starts the full Hindsight API on `localhost:8888` with an embedded PostgreSQL database (pg0). You then connect your MCP client to it over HTTP.
- Starts an embedded PostgreSQL (pg0) automatically
- Runs database migrations on startup
- Exposes the full MCP endpoint at `http://localhost:8888/mcp/`
- Data persists in `~/.pg0/hindsight-mcp/` across restarts
## Setup
### 1. Start the server
```bash
HINDSIGHT_API_LLM_API_KEY=sk-... uvx --from hindsight-api hindsight-local-mcp
```
Or with Ollama (no API key needed):
```bash
HINDSIGHT_API_LLM_PROVIDER=ollama HINDSIGHT_API_LLM_MODEL=llama3.2 uvx --from hindsight-api hindsight-local-mcp
```
### 2. Configure your MCP client
**Claude Code:**
```bash
claude mcp add --transport http hindsight http://localhost:8888/mcp/
```
**Other MCP clients** — add an HTTP transport entry pointing to `http://localhost:8888/mcp/`.
## Bank Modes
The local server supports the same two modes as the hosted API:
### Multi-bank mode (default)
Use `http://localhost:8888/mcp/` — exposes all tools including bank management. Bank is selected per-request via the `bank_id` tool parameter or the `X-Bank-Id` header.
```bash
claude mcp add --transport http hindsight http://localhost:8888/mcp/
```
### Single-bank mode
Use `http://localhost:8888/mcp/<bank-id>/` — pins all tools to one bank, no `bank_id` parameter needed. This replaces the old `HINDSIGHT_API_MCP_LOCAL_BANK_ID` env var.
```bash
claude mcp add --transport http hindsight http://localhost:8888/mcp/my-bank/
```
## Available Tools
The local server exposes the full tool set (29 tools in multi-bank mode, 26 in single-bank mode):
**Core Memory**
| Tool | Description |
|------|-------------|
| `retain` | Store information to long-term memory with optional tags, metadata, and document association |
| `recall` | Search memories with natural language, configurable budget, type filters, and tag filters |
| `reflect` | Synthesize memories into a reasoned answer with optional structured output |
**Mental Models**
| Tool | Description |
|------|-------------|
| `list_mental_models` | List pinned reflections for a bank |
| `get_mental_model` | Get a specific mental model |
| `create_mental_model` | Create a new mental model with optional auto-refresh trigger |
| `update_mental_model` | Update a mental model's metadata |
| `delete_mental_model` | Delete a mental model |
| `refresh_mental_model` | Regenerate a mental model's content |
**Directives**
| Tool | Description |
|------|-------------|
| `list_directives` | List directives that guide memory processing |
| `create_directive` | Create a new directive |
| `delete_directive` | Delete a directive |
**Memory Browsing**
| Tool | Description |
|------|-------------|
| `list_memories` | Browse memories with filtering and pagination |
| `get_memory` | Get a specific memory by ID |
| `delete_memory` | Delete a specific memory |
**Documents**
| Tool | Description |
|------|-------------|
| `list_documents` | List ingested documents |
| `get_document` | Get a specific document |
| `delete_document` | Delete a document and its linked memories |
**Operations**
| Tool | Description |
|------|-------------|
| `list_operations` | List async operations with status filtering |
| `get_operation` | Check operation status and progress |
| `cancel_operation` | Cancel a pending or running operation |
**Tags & Bank Management**
| Tool | Description |
|------|-------------|
| `list_tags` | List unique tags used in a bank |
| `get_bank` | Get bank profile (name, mission, disposition) |
| `get_bank_stats` | Get bank statistics (multi-bank only) |
| `update_bank` | Update bank name or mission |
| `delete_bank` | Delete an entire bank and all its data |
| `clear_memories` | Clear memories without deleting the bank |
| `list_banks` | List all memory banks (multi-bank only) |
| `create_bank` | Create or configure a memory bank (multi-bank only) |
For detailed parameter documentation, see the [MCP Server reference](/developer/mcp-server#available-tools).
## Environment Variables
All standard [Hindsight configuration variables](/developer/configuration) are supported. Key ones for local use:
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `HINDSIGHT_API_LLM_API_KEY` | Yes* | — | API key for your LLM provider |
| `HINDSIGHT_API_LLM_PROVIDER` | No | `openai` | LLM provider (`openai`, `anthropic`, `ollama`, etc.) |
| `HINDSIGHT_API_LLM_MODEL` | No | `gpt-4o-mini` | Model name |
| `HINDSIGHT_API_DATABASE_URL` | No | `pg0://hindsight-mcp` | Override the database URL |
| `HINDSIGHT_API_PORT` | No | `8888` | Port to listen on |
| `HINDSIGHT_API_LOG_LEVEL` | No | `info` | Log level |
*Not required when using a local provider like Ollama.
## Troubleshooting
### Slow first startup
The first startup downloads the local embedding model (~100MB) and initializes the database. Subsequent starts are faster.
### Port already in use
Set a different port:
```bash
HINDSIGHT_API_LLM_API_KEY=sk-... HINDSIGHT_API_PORT=9000 uvx --from hindsight-api hindsight-local-mcp
```
Then update your MCP client URL to `http://localhost:9000/mcp/`.
### Checking logs
Set `HINDSIGHT_API_LOG_LEVEL=debug` for verbose output:
```bash
HINDSIGHT_API_LLM_API_KEY=sk-... HINDSIGHT_API_LOG_LEVEL=debug uvx --from hindsight-api hindsight-local-mcp
```
@@ -1,250 +0,0 @@
---
sidebar_position: 5
title: "NemoClaw Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent memory to NemoClaw sandboxed agents with Hindsight. One command adds automated memory extraction and auto-recall to any NemoClaw sandbox — no code changes required."
---
# NemoClaw
Persistent memory for [NemoClaw](https://nemoclaw.ai) sandboxed agents using [Hindsight](https://hindsight.vectorize.io).
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with controlled filesystem, process, and network egress policies. The `hindsight-nemoclaw` package automates adding Hindsight memory to a sandbox in one command — no code changes required.
[View Changelog →](/changelog/integrations/nemoclaw)
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
## How It Works
### The sandbox problem
OpenShell enforces strict network egress — every outbound endpoint must be explicitly permitted in the sandbox policy. By default, the Hindsight API (`api.hindsight.vectorize.io`) is not in that list.
The `hindsight-openclaw` plugin supports **external API mode**, where it skips the local daemon entirely and makes direct HTTPS calls to Hindsight Cloud. This is the natural fit for sandboxed environments: the plugin becomes a thin HTTP client, and the only sandbox change needed is one egress rule.
### What the setup command does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads the current sandbox policy, merges the Hindsight egress block, and re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
### Memory flow
Once set up, the `hindsight-openclaw` plugin hooks into the OpenClaw gateway lifecycle:
- **`before_agent_start`** — recalls relevant memories from past sessions and injects them into context
- **`agent_end`** — retains the conversation to the Hindsight memory bank
The sandbox doesn't interfere with either step — it sees the Hindsight calls as normal HTTPS egress to a permitted endpoint.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
Use `--dry-run` to preview all changes before applying anything. Use `--skip-policy` if you manage sandbox policies manually.
## Manual Setup
If you prefer to apply the steps yourself instead of using the CLI:
### 1. Install the plugin
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### 2. Configure `~/.openclaw/openclaw.json`
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
`llmProvider: "claude-code"` uses the Claude Code process already present in the sandbox — no additional API key needed.
### 3. Add the Hindsight network policy
`openshell policy set` replaces the entire policy document. Export your current policy first, add the Hindsight block, then re-apply:
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| `hindsightApiUrl` | string | — | Hindsight API base URL |
| `hindsightApiToken` | string | — | API token for authentication |
| `llmProvider` | string | auto-detect | LLM provider for memory extraction |
| `dynamicBankId` | boolean | `false` | Isolate memory per user (`true`) or share across sessions (`false`) |
| `bankIdPrefix` | string | `"nemoclaw"` | Prefix for the memory bank name |
### Bank naming
When `dynamicBankId: false`, all sessions write to a single bank named `{bankIdPrefix}-openclaw`. When `dynamicBankId: true`, each user gets an isolated bank — useful for multi-tenant deployments.
## Verifying It Works
After setup, check the gateway logs:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
```
On startup you should see:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
After a conversation:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
## Pitfalls
### Policy replacement is full-document
`openshell policy set` replaces the entire policy document. The `hindsight-nemoclaw setup` command handles this automatically. If you're applying manually, export the current policy first so existing rules aren't lost.
### LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent under a restricted security context. `openclaw plugins install --link` creates a symlink the LaunchAgent can't follow — the setup command installs as a copy instead. If you see `EPERM: operation not permitted, scandir` in gateway logs, this is the cause.
### Memory retention is asynchronous
Fact extraction and entity resolution happen in the background after `retain`. If you open a new session immediately after closing one, the most recent memories may not be indexed yet — typically a few seconds.
### Binary-scoped egress
The `binaries` field in the network policy restricts the egress rule to a specific executable path. If OpenClaw updates and the binary path changes, the rule silently stops working. Check your binary path after upgrades.
## Troubleshooting
### Plugin not loading
```bash
openclaw plugins list | grep hindsight
# Should show: ✓ enabled │ Hindsight Memory │ ...
# Reinstall
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### Egress blocked
If calls to `api.hindsight.vectorize.io` are being blocked, check the active sandbox policy:
```bash
openshell sandbox get my-assistant
```
Verify the `hindsight` block is present and the `binaries` path matches your OpenClaw binary:
```bash
which openclaw
```
### External API not connecting
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# If you see daemon startup messages instead of "Using external API",
# the plugin config isn't being read — check ~/.openclaw/openclaw.json
```
@@ -1,374 +0,0 @@
---
sidebar_position: 4
title: "OpenClaw Persistent Memory with Hindsight | Plugin Integration"
description: "Add persistent, automated memory to your OpenClaw agent with Hindsight. Local-first, open source — one plugin install replaces built-in memory with structured knowledge extraction and auto-recall."
---
# OpenClaw
Local, long term memory for [OpenClaw](https://openclaw.ai) agents using [Hindsight](https://vectorize.io/hindsight).
This plugin integrates [hindsight-embed](https://vectorize.io/hindsight/cli), a standalone daemon that bundles Hindsight's memory engine (API + PostgreSQL) into a single command. Everything runs locally on your machine, reuses the LLM you're already paying for, and costs nothing extra.
[View Changelog →](/changelog/integrations/openclaw)
## Quick Start
**Step 1: Set up LLM for memory extraction**
Choose one provider and set its API key:
```bash
# Option A: OpenAI
export OPENAI_API_KEY="sk-your-key"
# Option B: Anthropic
export ANTHROPIC_API_KEY="your-key"
# Option C: Gemini
export GEMINI_API_KEY="your-key"
# Option D: Groq
export GROQ_API_KEY="your-key"
# Option E: Claude Code (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=claude-code
# Option F: OpenAI Codex (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
```
**Step 2: Install the plugin**
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
**Step 3: Start OpenClaw**
```bash
openclaw gateway
```
The plugin will automatically:
- Start a local Hindsight daemon (port 9077)
- Capture conversations after each turn
- Inject relevant memories before agent responses
**Important:** The LLM you configure above is **only for memory extraction** (background processing). Your main OpenClaw agent can use any model you configure separately.
## How It Works
**Auto-Capture:** Every conversation is automatically stored after each turn. Facts, entities, and relationships are extracted in the background.
**Auto-Recall:** Before each agent response, relevant memories are automatically injected into the context (up to 1024 tokens). The agent uses past context without needing to call tools.
**Feedback Loop Prevention:** The plugin automatically strips injected memory tags (`<hindsight_memories>`) before storing conversations. This prevents recalled memories from being re-extracted as new facts, which would cause exponential memory growth and duplicate entries.
Traditional memory systems give agents a `search_memory` tool - but models don't use it consistently. Auto-recall solves this by injecting memories automatically before every turn.
## Configuration
### Plugin Settings
Optional settings in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest"
}
}
}
}
}
```
**Options:**
- `apiPort` - Port for the openclaw profile daemon (default: `9077`)
- `daemonIdleTimeout` - Seconds before daemon shuts down from inactivity (default: `0` = never)
- `embedVersion` - hindsight-embed version (default: `"latest"`)
- `bankMission` - Agent identity/purpose stored on the memory bank. Helps the memory engine understand context for better fact extraction during retain. Set once per bank on first use — not a recall prompt.
- `dynamicBankId` - Enable per-context memory banks (default: `true`)
- `bankIdPrefix` - Optional prefix for bank IDs (e.g. `"prod"``"prod-slack-C123"`)
- `dynamicBankGranularity` - Fields used to derive bank ID: `agent`, `channel`, `user`, `provider` (default: `["agent", "channel", "user"]`)
- `excludeProviders` - Message providers to skip for recall/retain (e.g. `["slack"]`, `["telegram"]`, `["discord"]`)
- `autoRecall` - Auto-inject memories before each turn (default: `true`). Set to `false` when the agent has its own recall tool.
- `autoRetain` - Auto-retain conversations after each turn (default: `true`)
- `retainRoles` - Which message roles to retain (default: `["user", "assistant"]`). Options: `user`, `assistant`, `system`, `tool`
- `recallBudget` - Recall effort: `"low"`, `"mid"`, or `"high"` (default: `"mid"`). Higher budgets use more retrieval strategies for better results.
- `recallMaxTokens` - Max tokens for recall response (default: `1024`). Controls how much memory context is injected per turn.
- `recallTopK` - Max number of memories to inject per turn (default: unlimited).
- `recallTypes` - Memory types to recall (default: `["world", "experience"]`). Options: `world`, `experience`, `observation`.
- `recallContextTurns` - Number of prior user turns to include in the recall query (default: `1`).
- `recallMaxQueryChars` - Max characters for the composed recall query (default: `800`).
- `recallPromptPreamble` - Custom preamble text placed above recalled memories. Overrides the built-in guidance text.
- `recallInjectionPosition` - Where to inject recalled memories: `"prepend"` (default), `"append"`, or `"user"`. Use `"append"` to preserve prompt caching with large static system prompts. Use `"user"` to inject before the user message instead of in the system prompt.
- `recallRoles` - Which message roles to include when composing the contextual recall query (default: `["user", "assistant"]`).
- `retainEveryNTurns` - Retain every Nth turn (default: `1` = every turn). Values > 1 enable chunked retention.
- `retainOverlapTurns` - Extra prior turns included when chunked retention fires (default: `0`).
- `debug` - Enable debug logging (default: `false`).
### Memory Isolation
The plugin creates separate memory banks based on conversation context. By default, banks are derived from the `agent`, `channel`, and `user` fields — so each unique combination gets its own isolated memory store.
You can customize which fields are used for bank segmentation with `dynamicBankGranularity`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"dynamicBankGranularity": ["provider", "user"]
}
}
}
}
}
```
In this example, memories are isolated per provider + user, meaning the same user shares memories across all channels within a provider.
Available isolation fields:
- `agent` - The agent/bot identity
- `channel` - The channel or conversation ID
- `user` - The user interacting with the agent
- `provider` - The message provider (e.g. Slack, Discord)
Use `bankIdPrefix` to namespace bank IDs across environments (e.g. `"prod"`, `"staging"`). Set `dynamicBankId` to `false` to use a single shared bank for all conversations.
### Retention Controls
By default, the plugin retains `user` and `assistant` messages after each turn. You can customize this behavior:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"autoRetain": true,
"retainRoles": ["user", "assistant", "system"]
}
}
}
}
}
```
- `autoRetain` - Set to `false` to disable automatic retention entirely (useful if you handle retention yourself)
- `retainRoles` - Controls which message roles are included in the retained transcript. Only messages from the last user message onward are retained each turn, preventing duplicate storage.
### LLM Configuration
The plugin auto-detects your LLM provider from these environment variables:
| Provider | Env Var | Notes |
|----------|---------|-------|
| OpenAI | `OPENAI_API_KEY` | |
| Anthropic | `ANTHROPIC_API_KEY` | |
| Gemini | `GEMINI_API_KEY` | |
| Groq | `GROQ_API_KEY` | |
| Claude Code | `HINDSIGHT_API_LLM_PROVIDER=claude-code` | No API key needed |
| OpenAI Codex | `HINDSIGHT_API_LLM_PROVIDER=openai-codex` | No API key needed |
The model is selected automatically by the Hindsight API. To override, set `HINDSIGHT_API_LLM_MODEL`.
**Override with explicit config:**
```bash
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-your-key
# Optional: custom base URL (OpenRouter, Azure, vLLM, etc.)
export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1
```
**Example: Free OpenRouter model**
```bash
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_MODEL=xiaomi/mimo-v2-flash # FREE!
export HINDSIGHT_API_LLM_API_KEY=sk-or-v1-your-openrouter-key
export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1
```
### External API (Advanced)
Connect to a remote Hindsight API server instead of running a local daemon. This is useful for:
- **Shared memory** across multiple OpenClaw instances
- **Production deployments** with centralized memory storage
- **Team environments** where agents share knowledge
#### Plugin Configuration
Configure in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://your-hindsight-server.com",
"hindsightApiToken": "your-api-token"
}
}
}
}
}
```
**Options:**
- `hindsightApiUrl` - Full URL to external Hindsight API (e.g., `https://mcp.hindsight.example.com`)
- `hindsightApiToken` - API token for authentication (optional, only if API requires auth)
#### Environment Variables (Alternative)
You can also configure via environment variables:
```bash
export HINDSIGHT_EMBED_API_URL=https://your-hindsight-server.com
export HINDSIGHT_EMBED_API_TOKEN=your-api-token # Optional
openclaw gateway
```
**Note:** Plugin config takes precedence over environment variables.
#### Behavior
When external API mode is enabled:
- **No local daemon** is started (no hindsight-embed process)
- **Health check** runs on startup to verify API connectivity
- **All memory operations** (retain, recall, reflect) go to the external API
- **Faster startup** since no local PostgreSQL or embedding models are needed
#### Verification
Check OpenClaw logs for external API mode:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# Should see on startup:
# [Hindsight] External API mode enabled: https://your-hindsight-server.com
# [Hindsight] External API health check passed
```
If you see daemon startup messages instead, verify your configuration is correct.
## Inspecting Memories
### Check Configuration
View the daemon config that was written by the plugin:
```bash
cat ~/.hindsight/profiles/openclaw.env
```
This shows the LLM provider, model, port, and other settings the daemon is using.
### Check Daemon Status
```bash
# Check if daemon is running
uvx hindsight-embed@latest -p openclaw daemon status
# View daemon logs
tail -f ~/.hindsight/profiles/openclaw.log
```
### Query Memories
```bash
# Search memories
uvx hindsight-embed@latest -p openclaw memory recall openclaw "user preferences"
# View recent memories
uvx hindsight-embed@latest -p openclaw memory list openclaw --limit 10
# Open web UI (uses openclaw profile's daemon)
uvx hindsight-embed@latest -p openclaw ui
```
## Troubleshooting
### Plugin not loading
```bash
openclaw plugins list | grep hindsight
# Should show: ✓ enabled │ Hindsight Memory │ ...
# Reinstall if needed
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### Daemon not starting
```bash
# Check daemon status (note: -p openclaw uses the openclaw profile)
uvx hindsight-embed@latest -p openclaw daemon status
# View logs for errors
tail -f ~/.hindsight/profiles/openclaw.log
# Check configuration
cat ~/.hindsight/profiles/openclaw.env
# List all profiles
uvx hindsight-embed@latest profile list
```
### No API key error
Make sure you've set one of the provider API keys (or use a provider that doesn't require one):
```bash
# Option 1: OpenAI
export OPENAI_API_KEY="sk-your-key"
# Option 2: Anthropic
export ANTHROPIC_API_KEY="your-key"
# Option 3: Claude Code (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=claude-code
# Option 4: OpenAI Codex (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
# Verify it's set
echo $OPENAI_API_KEY
# or
echo $HINDSIGHT_API_LLM_PROVIDER
```
### Verify it's working
Check gateway logs for memory operations:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# Should see on startup:
# [Hindsight] ✓ Using provider: openai, model: gpt-4o-mini
# or
# [Hindsight] ✓ Using provider: claude-code, model: claude-sonnet-4-20250514
# Should see after conversations:
# [Hindsight] Retained X messages for session ...
# [Hindsight] Auto-recall: Injecting X memories
```
@@ -1,188 +0,0 @@
---
sidebar_position: 6
title: "Pydantic AI Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to Pydantic AI agents with Hindsight. Async-native retain, recall, and reflect tools — persistent memory across all agent runs with no thread-pool hacks."
---
# Pydantic AI
Persistent memory tools for [Pydantic AI](https://ai.pydantic.dev/) agents via Hindsight. Give your agents long-term memory with retain, recall, and reflect — all async-native with no thread-pool hacks.
[View Changelog →](/changelog/integrations/pydantic-ai)
## Features
- **Async-Native Tools** — Uses Pydantic AI's async tool interface directly (`aretain`, `arecall`, `areflect`)
- **Memory Instructions** — Auto-inject relevant memories into every agent run via `instructions=[...]`
- **Three Memory Tools** — Retain (store), Recall (search), Reflect (synthesize) — include any combination
- **Simple Configuration** — Configure once globally, or pass a client directly
- **Lightweight** — Depends on `pydantic-ai-slim` to avoid pulling in all model providers
## Installation
```bash
pip install hindsight-pydantic-ai
```
## Quick Start
```python
from hindsight_client import Hindsight
from hindsight_pydantic_ai import create_hindsight_tools, memory_instructions
from pydantic_ai import Agent
client = Hindsight(base_url="http://localhost:8888")
agent = Agent(
"openai:gpt-4o",
tools=create_hindsight_tools(client=client, bank_id="user-123"),
instructions=[memory_instructions(client=client, bank_id="user-123")],
)
result = await agent.run("What do you remember about my preferences?")
print(result.output)
```
The agent now has three tools it can call:
- **`hindsight_retain`** — Store information to long-term memory
- **`hindsight_recall`** — Search long-term memory for relevant facts
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
The `memory_instructions` callable automatically recalls relevant memories and injects them into the system prompt on every run.
## Tools Only (No Auto-Injection)
If you want the agent to decide when to use memory rather than always injecting context:
```python
agent = Agent(
"openai:gpt-4o",
tools=create_hindsight_tools(client=client, bank_id="user-123"),
)
```
## Instructions Only (No Tools)
If you just want memories auto-injected without giving the agent explicit memory tools:
```python
agent = Agent(
"openai:gpt-4o",
instructions=[memory_instructions(client=client, bank_id="user-123")],
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=True,
include_recall=True,
include_reflect=False, # Omit reflect
)
```
## Global Configuration
Instead of passing a client to every call, configure once:
```python
from hindsight_pydantic_ai import configure, create_hindsight_tools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create tools without passing client — uses global config
tools = create_hindsight_tools(bank_id="user-123")
```
## Per-Tool Overrides
Constructor arguments override global configuration:
```python
tools = create_hindsight_tools(
bank_id="user-123",
budget="high", # Override global budget
max_tokens=8192, # Override global max_tokens
tags=["session:abc"], # Override global tags
)
```
## Memory Instructions Options
Customize what memories get injected and how:
```python
instructions_fn = memory_instructions(
client=client,
bank_id="user-123",
query="relevant context about the user", # What to search for
budget="low", # Keep it fast
max_results=5, # Limit injected memories
max_tokens=4096, # Max recall tokens
prefix="Relevant memories:\n", # Text before the memory list
tags=["scope:global"], # Filter by tags
tags_match="any", # Tag match mode
)
```
## API Reference
### `create_hindsight_tools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode |
| `include_retain` | `True` | Include the retain (store) tool |
| `include_recall` | `True` | Include the recall (search) tool |
| `include_reflect` | `True` | Include the reflect (synthesize) tool |
### `memory_instructions()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `query` | `"relevant context about the user"` | Recall query for memory injection |
| `budget` | `"low"` | Recall budget level |
| `max_results` | `5` | Maximum memories to inject |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `prefix` | `"Relevant memories:\n"` | Text prepended before memory list |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
@@ -1,325 +0,0 @@
---
sidebar_position: 3
title: "Hindsight Agent Memory Skill | AI Coding Assistant Integration"
description: "Give AI coding assistants like Claude Code and Codex persistent memory across sessions with Hindsight's Agent Skill — a reusable prompt template for long-term context retention."
---
# Skills
Hindsight provides an Agent Skill that gives AI coding assistants persistent memory across sessions. Skills are reusable prompt templates that agents can load when needed to gain specialized capabilities.
## Supported Platforms
| Platform | Skills Directory |
|----------|-----------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/skills/` |
| [OpenCode](https://github.com/opencode-ai/opencode) | `~/.opencode/skills/` |
| [Codex CLI](https://github.com/openai/codex) | `~/.codex/skills/` |
## Deployment Modes
The skill supports two deployment modes:
| Mode | Best For | Data Location |
|------|----------|---------------|
| **Local** | Individual developers | Your machine (`~/.pg0/`) |
| **Cloud** | Teams sharing knowledge | Hindsight Cloud |
## Quick Install
### Option 1: Interactive Installer (Recommended)
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
```
The installer will:
1. Prompt you to select your AI coding assistant
2. Select deployment mode (local or cloud)
3. Configure the appropriate settings
4. Install the skill to the appropriate directory
### Install for a Specific Platform
```bash
# Claude Code (interactive mode selection)
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude
# OpenCode
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app opencode
# Codex CLI
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app codex
```
### Install with Cloud Mode
```bash
# Direct cloud setup (skips interactive prompts for mode)
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude --mode cloud
```
### Option 2: Using add-skill
If you use [add-skill](https://add-skill.org/) to manage your agent skills:
```bash
# For local mode (individual developers)
npx add-skill vectorize-io/hindsight --skill hindsight-local
# For Hindsight Cloud (teams)
npx add-skill vectorize-io/hindsight --skill hindsight-cloud
# For self-hosted Hindsight servers
npx add-skill vectorize-io/hindsight --skill hindsight-self-hosted
```
On first use, the AI will guide you through the remaining setup:
- **Local**: Run `uvx hindsight-embed configure` to set up your LLM provider
- **Cloud**: Provide your API key and bank ID
- **Self-hosted**: Provide your server URL, API key, and bank ID
## What the Skill Provides
Once installed, your AI assistant gains the ability to:
- **Retain** - Store user preferences, learnings, and procedure outcomes
- **Recall** - Search for relevant context before starting tasks
- **Reflect** - Synthesize memories into contextual answers
The skill uses the `hindsight-embed` CLI which runs a lightweight local daemon with an embedded database.
## How Skills Work
Skills are **model-invoked**, meaning the AI assistant automatically decides when to use them based on the context of your conversation. You don't need to explicitly trigger the skill.
The assistant will:
- **Store** when you share preferences, when tasks succeed/fail, or when learnings emerge
- **Recall** before starting non-trivial tasks to get relevant context
### What Gets Stored
The skill is optimized to store:
| Category | Examples |
|----------|----------|
| **User Preferences** | Coding style, tool preferences, language choices |
| **Procedure Outcomes** | Commands that worked, configurations that resolved issues |
| **Learnings** | Bug solutions, workarounds, architecture decisions |
## Architecture
### Local Mode
```
AI Coding Assistant
Hindsight Skill (SKILL.md)
hindsight-embed CLI
Local Daemon (auto-started)
Embedded PostgreSQL (~/.pg0/hindsight-embed/)
```
All data stays on your machine. The daemon auto-starts when needed and shuts down after inactivity.
### Cloud Mode
```
AI Coding Assistant
Hindsight Skill (SKILL.md)
hindsight-cli
Hindsight Cloud API (https://api.hindsight.vectorize.io)
Shared Memory Bank (team-accessible)
```
Data is stored in Hindsight Cloud and shared across your team. All team members with the same bank ID can access shared memories.
---
## Local Mode Setup
The skill uses configuration stored in `~/.hindsight/config.env`. Reconfigure anytime:
```bash
uvx hindsight-embed configure
```
---
## Cloud Mode Setup
Cloud mode connects to [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup), allowing teams to share memories about a codebase. When one team member learns something, everyone benefits.
### Prerequisites
1. A Hindsight Cloud account ([sign up](https://ui.hindsight.vectorize.io/signup))
2. An API key from your team admin
3. A bank ID for your project (e.g., `team-acme-frontend`)
### Installation
Run the installer with cloud mode:
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --mode cloud
```
You'll be prompted for:
| Setting | Description | Example |
|---------|-------------|---------|
| **Cloud API URL** | Hindsight Cloud endpoint | `https://api.hindsight.vectorize.io` |
| **API Key** | Your authentication key | `hs_xxx...` |
| **Bank ID** | Shared memory bank for your team | `team-acme-frontend` |
### Configuration Files
Cloud mode creates two files:
**`~/.hindsight/config`** — API connection settings (TOML format):
```toml
api_url = "https://api.hindsight.vectorize.io"
api_key = "hs_xxx..."
```
**`~/.claude/skills/hindsight/SKILL.md`** — Skill definition with your bank ID baked in.
### Team Setup
To set up cloud mode for your team:
1. **Team admin** creates a bank in Hindsight Cloud (e.g., `team-acme-frontend`)
2. **Team admin** generates API keys for each team member
3. **Each developer** runs the installer with their API key and the shared bank ID
4. All team members now share the same memory bank
### What to Store in Team Banks
Cloud mode uses a **shared team bank**. Be thoughtful about what goes in:
| Type | Examples | How to Store |
|------|----------|--------------|
| **Project conventions** | Linting rules, testing requirements, Node version | `"Project uses ESLint with Airbnb config"` |
| **Team knowledge** | Architecture decisions, common pitfalls, domain logic | `"Auth module requires Redis 7+"` |
| **Individual preferences** | Personal coding style, communication preferences | `"Alice prefers verbose commit messages"` |
**Key distinction**: Project conventions apply to everyone. Individual preferences should include the person's name so the AI knows when to apply them.
### Example Workflow
```
Day 1: Alice discovers a requirement
─────────────────────────────────────
Alice's AI assistant stores:
"The auth module requires Redis 7+ due to HEXPIRE command usage"
"Alice prefers explicit error handling over silent failures"
Day 2: Bob starts working on auth
─────────────────────────────────
Bob's AI assistant recalls:
"The auth module requires Redis 7+ due to HEXPIRE command usage"
Bob avoids the same issue Alice hit!
(Alice's personal preference is stored but won't be applied to Bob)
```
### Testing Cloud Connection
After installation, verify the connection:
```bash
# Store a test memory
hindsight memory retain team-acme-frontend "Alice works at Google as a software engineer"
# Recall it
hindsight memory recall team-acme-frontend "Alice"
```
### Switching Between Banks
If you work on multiple projects, you can have different skills installed for each AI assistant, or manually switch banks:
```bash
# Environment variable override (temporary)
HINDSIGHT_API_URL=https://api.hindsight.vectorize.io \
HINDSIGHT_API_KEY=hs_xxx \
hindsight memory recall different-bank "query"
```
For permanent multi-bank setups, reinstall the skill with a different bank ID.
## Troubleshooting
### Skill not activating
The skill activates based on its description matching your request. Try being explicit:
- "Remember that..." triggers storage
- "What do you know about..." triggers recall
### Local Mode Issues
**Daemon not starting:**
```bash
uvx hindsight-embed daemon status
uvx hindsight-embed daemon logs
```
**Reconfigure LLM provider:**
```bash
uvx hindsight-embed configure
```
### Cloud Mode Issues
**Authentication errors:**
```bash
# Verify your config
cat ~/.hindsight/config
# Test connection manually
hindsight bank list
```
**Wrong bank ID:**
Check your SKILL.md file to see which bank ID is configured:
```bash
cat ~/.claude/skills/hindsight/SKILL.md | grep "memory retain"
```
To change the bank ID, reinstall the skill:
```bash
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --mode cloud
```
**Network/firewall issues:**
```bash
# Test connectivity to cloud API
curl -I https://api.hindsight.vectorize.io/health
```
## Requirements
### Local Mode
- Python 3.10+ (for `uvx`)
- An LLM API key (OpenAI, Anthropic, Groq, etc.)
### Cloud Mode
- Python 3.10+ (for `uvx`)
- Hindsight Cloud API key
- Network access to `https://api.hindsight.vectorize.io`
@@ -1,157 +0,0 @@
---
sidebar_position: 13
title: "Strands Agents Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to Strands Agents SDK agents with Hindsight. Retain, recall, and reflect tools using Strands' native @tool pattern for persistent memory across sessions."
---
# Strands Agents
Persistent memory tools for [Strands Agents SDK](https://github.com/strands-agents/sdk-python) agents via Hindsight. Give your agents long-term memory with retain, recall, and reflect — using Strands' native `@tool` pattern.
## Features
- **Native `@tool` Functions** - Tools are plain Python functions, compatible with `Agent(tools=[...])`
- **Memory Instructions** - Pre-recall memories for injection into agent system prompt
- **Three Memory Tools** - Retain (store), Recall (search), Reflect (synthesize) — include any combination
- **Simple Configuration** - Configure once globally, or pass a client directly
## Installation
```bash
pip install hindsight-strands
```
## Quick Start
```python
from strands import Agent
from hindsight_strands import create_hindsight_tools
tools = create_hindsight_tools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
agent = Agent(tools=tools)
agent("Remember that I prefer dark mode")
agent("What are my preferences?")
```
The agent now has three tools it can call:
- **`hindsight_retain`** — Store information to long-term memory
- **`hindsight_recall`** — Search long-term memory for relevant facts
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
## With Memory Instructions
Pre-recall relevant memories and inject them into the system prompt:
```python
from hindsight_strands import create_hindsight_tools, memory_instructions
tools = create_hindsight_tools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
memories = memory_instructions(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
agent = Agent(
tools=tools,
system_prompt=f"You are a helpful assistant.\n\n{memories}",
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = create_hindsight_tools(
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
enable_retain=True,
enable_recall=True,
enable_reflect=False, # Omit reflect
)
```
## Global Configuration
Instead of passing connection details to every call, configure once:
```python
from hindsight_strands import configure, create_hindsight_tools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create tools without passing connection details
tools = create_hindsight_tools(bank_id="user-123")
```
## Configuration Reference
### `create_hindsight_tools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode |
| `enable_retain` | `True` | Include the retain (store) tool |
| `enable_recall` | `True` | Include the recall (search) tool |
| `enable_reflect` | `True` | Include the reflect (synthesize) tool |
### `memory_instructions()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `query` | `"relevant context about the user"` | Recall query for memory injection |
| `budget` | `"low"` | Recall budget level |
| `max_results` | `5` | Maximum memories to inject |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `prefix` | `"Relevant memories:\n"` | Text prepended before memory list |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
## Requirements
- Python >= 3.10
- strands-agents
- hindsight-client >= 0.4.0
- A running Hindsight API server
@@ -5,14 +5,70 @@
"label": "Architecture",
"collapsible": false,
"items": [
{ "type": "doc", "id": "developer/index", "label": "Overview", "customProps": { "icon": "lu-book" } },
{ "type": "doc", "id": "developer/retain", "label": "Retain", "customProps": { "icon": "lu-brain" } },
{ "type": "doc", "id": "developer/retrieval", "label": "Recall", "customProps": { "icon": "lu-search" } },
{ "type": "doc", "id": "developer/reflect", "label": "Reflect", "customProps": { "icon": "lu-message" } },
{ "type": "doc", "id": "developer/multilingual", "label": "Multilingual", "customProps": { "icon": "lu-languages" } },
{ "type": "doc", "id": "developer/performance", "label": "Performance", "customProps": { "icon": "lu-zap" } },
{ "type": "doc", "id": "developer/storage", "label": "Storage", "customProps": { "icon": "lu-database" } },
{ "type": "doc", "id": "developer/rag-vs-hindsight", "label": "RAG vs Memory", "customProps": { "icon": "lu-compare" } }
{
"type": "doc",
"id": "developer/index",
"label": "Overview",
"customProps": {
"icon": "lu-book"
}
},
{
"type": "doc",
"id": "developer/retain",
"label": "Retain",
"customProps": {
"icon": "lu-brain"
}
},
{
"type": "doc",
"id": "developer/retrieval",
"label": "Recall",
"customProps": {
"icon": "lu-search"
}
},
{
"type": "doc",
"id": "developer/reflect",
"label": "Reflect",
"customProps": {
"icon": "lu-message"
}
},
{
"type": "doc",
"id": "developer/multilingual",
"label": "Multilingual",
"customProps": {
"icon": "lu-languages"
}
},
{
"type": "doc",
"id": "developer/performance",
"label": "Performance",
"customProps": {
"icon": "lu-zap"
}
},
{
"type": "doc",
"id": "developer/storage",
"label": "Storage",
"customProps": {
"icon": "lu-database"
}
},
{
"type": "doc",
"id": "developer/rag-vs-hindsight",
"label": "RAG vs Memory",
"customProps": {
"icon": "lu-compare"
}
}
]
},
{
@@ -20,14 +76,70 @@
"label": "API",
"collapsible": false,
"items": [
{ "type": "doc", "id": "developer/api/quickstart", "label": "Quick Start", "customProps": { "icon": "lu-rocket" } },
{ "type": "doc", "id": "developer/api/retain", "label": "Retain", "customProps": { "icon": "lu-brain" } },
{ "type": "doc", "id": "developer/api/recall", "label": "Recall", "customProps": { "icon": "lu-search" } },
{ "type": "doc", "id": "developer/api/reflect", "label": "Reflect", "customProps": { "icon": "lu-message" } },
{ "type": "doc", "id": "developer/api/memory-banks", "label": "Memory Banks", "customProps": { "icon": "lu-memory" } },
{ "type": "doc", "id": "developer/api/entities", "label": "Entities", "customProps": { "icon": "lu-network" } },
{ "type": "doc", "id": "developer/api/documents", "label": "Documents", "customProps": { "icon": "lu-file" } },
{ "type": "doc", "id": "developer/api/operations", "label": "Operations", "customProps": { "icon": "lu-cpu" } }
{
"type": "doc",
"id": "developer/api/quickstart",
"label": "Quick Start",
"customProps": {
"icon": "lu-rocket"
}
},
{
"type": "doc",
"id": "developer/api/retain",
"label": "Retain",
"customProps": {
"icon": "lu-brain"
}
},
{
"type": "doc",
"id": "developer/api/recall",
"label": "Recall",
"customProps": {
"icon": "lu-search"
}
},
{
"type": "doc",
"id": "developer/api/reflect",
"label": "Reflect",
"customProps": {
"icon": "lu-message"
}
},
{
"type": "doc",
"id": "developer/api/memory-banks",
"label": "Memory Banks",
"customProps": {
"icon": "lu-memory"
}
},
{
"type": "doc",
"id": "developer/api/entities",
"label": "Entities",
"customProps": {
"icon": "lu-network"
}
},
{
"type": "doc",
"id": "developer/api/documents",
"label": "Documents",
"customProps": {
"icon": "lu-file"
}
},
{
"type": "doc",
"id": "developer/api/operations",
"label": "Operations",
"customProps": {
"icon": "lu-cpu"
}
}
]
},
{
@@ -35,9 +147,30 @@
"label": "Clients",
"collapsible": false,
"items": [
{ "type": "doc", "id": "sdks/python", "label": "Python", "customProps": { "icon": "si-python" } },
{ "type": "doc", "id": "sdks/nodejs", "label": "TypeScript", "customProps": { "icon": "/img/icons/typescript.png" } },
{ "type": "doc", "id": "sdks/cli", "label": "CLI", "customProps": { "icon": "lu-terminal" } }
{
"type": "doc",
"id": "sdks/python",
"label": "Python",
"customProps": {
"icon": "si-python"
}
},
{
"type": "doc",
"id": "sdks/nodejs",
"label": "TypeScript",
"customProps": {
"icon": "/img/icons/typescript.png"
}
},
{
"type": "doc",
"id": "sdks/cli",
"label": "CLI",
"customProps": {
"icon": "lu-terminal"
}
}
]
},
{
@@ -45,9 +178,30 @@
"label": "Integrations",
"collapsible": false,
"items": [
{ "type": "doc", "id": "sdks/integrations/local-mcp", "label": "Local MCP Server", "customProps": { "icon": "/img/icons/mcp.png" } },
{ "type": "doc", "id": "sdks/integrations/litellm", "label": "LiteLLM", "customProps": { "icon": "/img/icons/litellm.png" } },
{ "type": "doc", "id": "sdks/integrations/skills", "label": "Skills", "customProps": { "icon": "/img/icons/skills.png" } }
{
"type": "link",
"label": "Local MCP Server",
"customProps": {
"icon": "/img/icons/mcp.png"
},
"href": "/sdks/integrations/local-mcp"
},
{
"type": "link",
"label": "LiteLLM",
"customProps": {
"icon": "/img/icons/litellm.png"
},
"href": "/sdks/integrations/litellm"
},
{
"type": "link",
"label": "Skills",
"customProps": {
"icon": "/img/icons/skills.png"
},
"href": "/sdks/integrations/skills"
}
]
},
{
@@ -55,14 +209,70 @@
"label": "Hosting",
"collapsible": false,
"items": [
{ "type": "doc", "id": "developer/installation", "label": "Installation", "customProps": { "icon": "lu-package" } },
{ "type": "doc", "id": "developer/services", "label": "Services", "customProps": { "icon": "lu-server" } },
{ "type": "doc", "id": "developer/configuration", "label": "Configuration", "customProps": { "icon": "lu-settings" } },
{ "type": "doc", "id": "developer/admin-cli", "label": "Admin CLI", "customProps": { "icon": "lu-terminal" } },
{ "type": "doc", "id": "developer/extensions", "label": "Extensions", "customProps": { "icon": "lu-plug" } },
{ "type": "doc", "id": "developer/models", "label": "Models", "customProps": { "icon": "lu-cpu" } },
{ "type": "doc", "id": "developer/monitoring", "label": "Monitoring", "customProps": { "icon": "lu-activity" } },
{ "type": "doc", "id": "developer/mcp-server", "label": "MCP Server", "customProps": { "icon": "lu-network" } }
{
"type": "doc",
"id": "developer/installation",
"label": "Installation",
"customProps": {
"icon": "lu-package"
}
},
{
"type": "doc",
"id": "developer/services",
"label": "Services",
"customProps": {
"icon": "lu-server"
}
},
{
"type": "doc",
"id": "developer/configuration",
"label": "Configuration",
"customProps": {
"icon": "lu-settings"
}
},
{
"type": "doc",
"id": "developer/admin-cli",
"label": "Admin CLI",
"customProps": {
"icon": "lu-terminal"
}
},
{
"type": "doc",
"id": "developer/extensions",
"label": "Extensions",
"customProps": {
"icon": "lu-plug"
}
},
{
"type": "doc",
"id": "developer/models",
"label": "Models",
"customProps": {
"icon": "lu-cpu"
}
},
{
"type": "doc",
"id": "developer/monitoring",
"label": "Monitoring",
"customProps": {
"icon": "lu-activity"
}
},
{
"type": "doc",
"id": "developer/mcp-server",
"label": "MCP Server",
"customProps": {
"icon": "lu-network"
}
}
]
}
]
@@ -220,140 +220,140 @@
"collapsible": false,
"items": [
{
"type": "doc",
"id": "sdks/integrations/local-mcp",
"type": "link",
"label": "Local MCP Server",
"customProps": {
"icon": "/img/icons/mcp.png"
}
},
"href": "/sdks/integrations/local-mcp"
},
{
"type": "doc",
"id": "sdks/integrations/litellm",
"type": "link",
"label": "LiteLLM",
"customProps": {
"icon": "/img/icons/litellm.png"
}
},
"href": "/sdks/integrations/litellm"
},
{
"type": "doc",
"id": "sdks/integrations/claude-code",
"type": "link",
"label": "Claude Code",
"customProps": {
"icon": "/img/icons/claudecode.svg"
}
},
"href": "/sdks/integrations/claude-code"
},
{
"type": "doc",
"id": "sdks/integrations/codex",
"type": "link",
"label": "OpenAI Codex CLI",
"customProps": {
"icon": "/img/icons/terminal.svg"
}
},
"href": "/sdks/integrations/codex"
},
{
"type": "doc",
"id": "sdks/integrations/openclaw",
"type": "link",
"label": "OpenClaw",
"customProps": {
"icon": "/img/icons/openclaw.png"
}
},
"href": "/sdks/integrations/openclaw"
},
{
"type": "doc",
"id": "sdks/integrations/ai-sdk",
"type": "link",
"label": "Vercel AI SDK",
"customProps": {
"icon": "/img/icons/vercel.png"
}
},
"href": "/sdks/integrations/ai-sdk"
},
{
"type": "doc",
"id": "sdks/integrations/chat",
"type": "link",
"label": "Vercel Chat SDK",
"customProps": {
"icon": "/img/icons/vercel.png"
}
},
"href": "/sdks/integrations/chat"
},
{
"type": "doc",
"id": "sdks/integrations/crewai",
"type": "link",
"label": "CrewAI",
"customProps": {
"icon": "/img/icons/crewai.png"
}
},
"href": "/sdks/integrations/crewai"
},
{
"type": "doc",
"id": "sdks/integrations/pydantic-ai",
"type": "link",
"label": "Pydantic AI",
"customProps": {
"icon": "/img/icons/pydanticai.png"
}
},
"href": "/sdks/integrations/pydantic-ai"
},
{
"type": "doc",
"id": "sdks/integrations/agno",
"type": "link",
"label": "Agno",
"customProps": {
"icon": "/img/icons/agno.png"
}
},
"href": "/sdks/integrations/agno"
},
{
"type": "doc",
"id": "sdks/integrations/hermes",
"type": "link",
"label": "Hermes Agent",
"customProps": {
"icon": "/img/icons/hermes.png"
}
},
"href": "/sdks/integrations/hermes"
},
{
"type": "doc",
"id": "sdks/integrations/langgraph",
"type": "link",
"label": "LangGraph / LangChain",
"customProps": {
"icon": "/img/icons/langgraph.png"
}
},
"href": "/sdks/integrations/langgraph"
},
{
"type": "doc",
"id": "sdks/integrations/nemoclaw",
"type": "link",
"label": "NemoClaw",
"customProps": {
"icon": "/img/icons/nemoclaw.png"
}
},
"href": "/sdks/integrations/nemoclaw"
},
{
"type": "doc",
"id": "sdks/integrations/strands",
"type": "link",
"label": "Strands Agents",
"customProps": {
"icon": "/img/icons/strands.png"
}
},
"href": "/sdks/integrations/strands"
},
{
"type": "doc",
"id": "sdks/integrations/ag2",
"type": "link",
"label": "AG2",
"customProps": {
"icon": "/img/icons/ag2.svg"
}
},
"href": "/sdks/integrations/ag2"
},
{
"type": "doc",
"id": "sdks/integrations/llamaindex",
"type": "link",
"label": "LlamaIndex",
"customProps": {
"icon": "/img/icons/llamaindex.png"
}
},
"href": "/sdks/integrations/llamaindex"
},
{
"type": "doc",
"id": "sdks/integrations/skills",
"type": "link",
"label": "Skills",
"customProps": {
"icon": "/img/icons/skills.png"
}
},
"href": "/sdks/integrations/skills"
}
]
},
@@ -419,6 +419,8 @@ class DaemonEmbedManager(EmbedManager):
"""
try:
api_config = {k: v for k, v in config.items() if k.startswith("HINDSIGHT_API_")}
if not api_config:
return
self._profile_manager.create_profile(profile, port, api_config)
except Exception as e:
logger.debug(f"Failed to register profile '{profile}' in metadata: {e}")
@@ -4,7 +4,6 @@ Handles creation, deletion, and management of configuration profiles.
Each profile has its own config, daemon lock, log file, and port.
"""
import fcntl
import hashlib
import json
import os
@@ -14,6 +13,50 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# ==============================================================================
# Cross-platform file locking implementation
# ==============================================================================
# Why not use a library like portalocker or fasteners?
#
# 1. Minimal dependency: Our use case is extremely simple - only basic
# exclusive file locking for metadata persistence. Adding a new dependency
# (even a small one) for such a narrow use case is unnecessary.
#
# 2. Portability: We only need to support the two major platforms (Unix and
# Windows), both of which have well-understood file locking mechanisms
# that can be implemented in ~10 lines of code each.
#
# 3. Maintainability: The code is straightforward and has no external
# dependencies to track or update. The locking logic is localized here,
# making it easy to understand and modify if needed.
#
# 4. Feature scope: Libraries like portalocker provide many features we don't
# need (timeout handling, shared locks, lock files, etc.), which would add
# unnecessary complexity to our simple use case.
#
# If our locking requirements become more complex in the future (e.g., needing
# timeouts, better error handling, or supporting more edge cases), reconsider
# using a dedicated library like portalocker.
# ==============================================================================
if sys.platform != "win32":
import fcntl
def lock_file(file_obj):
fcntl.flock(file_obj.fileno(), fcntl.LOCK_EX)
def unlock_file(file_obj):
fcntl.flock(file_obj.fileno(), fcntl.LOCK_UN)
else:
import msvcrt
def lock_file(file_obj):
msvcrt.locking(file_obj.fileno(), msvcrt.LK_LOCK, 1)
def unlock_file(file_obj):
msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1)
import httpx
# Configuration paths
@@ -470,8 +513,8 @@ class ProfileManager:
temp_file = metadata_file.with_suffix(".json.tmp")
with open(temp_file, "w") as f:
# Acquire exclusive lock
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
# Acquire exclusive lock (cross-platform)
lock_file(f)
try:
json.dump(
{"version": metadata.version, "profiles": metadata.profiles},
@@ -481,7 +524,7 @@ class ProfileManager:
f.flush()
os.fsync(f.fileno())
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
unlock_file(f)
# Atomic rename
temp_file.rename(metadata_file)
@@ -1,6 +1,9 @@
"""Tests for EmbedManager interface."""
from unittest.mock import MagicMock
from hindsight_embed import get_embed_manager
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
def test_sanitize_profile_name_via_db_url():
@@ -51,3 +54,43 @@ def test_manager_singleton():
# They should produce the same results
assert manager1.get_database_url("test") == manager2.get_database_url("test")
def test_register_profile_skips_when_no_api_keys():
"""
When config contains only short keys (no HINDSIGHT_API_* prefix),
_register_profile should not call create_profile, preserving any
existing profile .env file.
Regression test for https://github.com/vectorize-io/hindsight/issues/894
"""
manager = DaemonEmbedManager()
manager._profile_manager = MagicMock()
# Config with short keys (as passed from cli.py's get_config())
config = {"llm_api_key": "sk-123", "llm_provider": "openai", "llm_model": "gpt-4o"}
manager._register_profile("myprofile", 8100, config)
manager._profile_manager.create_profile.assert_not_called()
def test_register_profile_calls_create_when_api_keys_present():
"""
When config contains HINDSIGHT_API_* keys, _register_profile should
forward them to create_profile.
"""
manager = DaemonEmbedManager()
manager._profile_manager = MagicMock()
config = {
"HINDSIGHT_API_LLM_PROVIDER": "openai",
"HINDSIGHT_API_LLM_API_KEY": "sk-123",
"some_internal_key": "ignored",
}
manager._register_profile("myprofile", 8100, config)
manager._profile_manager.create_profile.assert_called_once_with(
"myprofile",
8100,
{"HINDSIGHT_API_LLM_PROVIDER": "openai", "HINDSIGHT_API_LLM_API_KEY": "sk-123"},
)
+144
View File
@@ -0,0 +1,144 @@
# @vectorize-io/opencode-hindsight
Hindsight memory plugin for [OpenCode](https://opencode.ai) — give your AI coding agent persistent long-term memory across sessions.
## Features
- **Custom tools**: `hindsight_retain`, `hindsight_recall`, `hindsight_reflect` — the agent calls these explicitly
- **Auto-retain**: Captures conversation on `session.idle` and stores to Hindsight
- **Memory injection**: Recalls relevant memories when a new session starts
- **Compaction hook**: Injects memories during context compaction so they survive window trimming
## Quick Start
### 1. Install
```bash
npm install @vectorize-io/opencode-hindsight
```
### 2. Configure
Add to your `opencode.json`:
```json
{
"plugin": ["@vectorize-io/opencode-hindsight"]
}
```
### 3. Set Environment Variables
```bash
# Required: Hindsight API URL
export HINDSIGHT_API_URL="http://localhost:8888"
# Optional: API key for Hindsight Cloud
export HINDSIGHT_API_TOKEN="your-api-key"
# Optional: Override the memory bank ID
export HINDSIGHT_BANK_ID="my-project"
```
## Configuration
### Plugin Options
Pass options directly in `opencode.json`:
```json
{
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "http://localhost:8888",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid"
}]
]
}
```
### Config File
Create `~/.hindsight/opencode.json` for persistent configuration:
```json
{
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"recallBudget": "mid",
"retainEveryNTurns": 10,
"debug": false
}
```
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) |
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) |
| `HINDSIGHT_DEBUG` | Enable debug logging | `false` |
### Configuration Priority
Settings are loaded in this order (later wins):
1. Built-in defaults
2. `~/.hindsight/opencode.json`
3. Plugin options from `opencode.json`
4. Environment variables
## Tools
### `hindsight_retain`
Store information in long-term memory. The agent uses this to save important facts, user preferences, project context, and decisions.
### `hindsight_recall`
Search long-term memory. The agent uses this proactively before answering questions where prior context would help.
### `hindsight_reflect`
Generate a synthesized answer from long-term memory. Unlike recall (raw memories), reflect produces a coherent summary.
## Dynamic Bank IDs
For multi-project setups, enable dynamic bank ID derivation:
```bash
export HINDSIGHT_DYNAMIC_BANK_ID=true
```
The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`.
**Note:** The bank ID is derived once when the plugin loads, from environment variables set before OpenCode starts. These dimensions are process-scoped — they don't change per session within a running OpenCode process. For per-user isolation, set the env vars before launching each user's OpenCode instance:
```bash
export HINDSIGHT_CHANNEL_ID="slack-general"
export HINDSIGHT_USER_ID="user123"
```
## Development
```bash
npm install
npm test # Run tests
npm run build # Build to dist/
```
## License
MIT
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
{
"name": "@vectorize-io/opencode-hindsight",
"version": "0.1.0",
"description": "Hindsight memory plugin for OpenCode - Give your AI coding agent persistent long-term memory",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"opencode",
"ai",
"memory",
"hindsight",
"agents",
"llm",
"long-term-memory",
"coding-agent"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/opencode"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.0.0"
},
"dependencies": {
"@vectorize-io/hindsight-client": "^0.4.19"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
},
"overrides": {
"rollup": "^4.59.0",
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4"
}
}
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { deriveBankId, ensureBankMission } from './bank.js';
import { makeConfig } from './test-helpers.js';
describe('deriveBankId', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns default bank name in static mode', () => {
expect(deriveBankId(makeConfig(), '/home/user/project')).toBe('opencode');
});
it('returns configured bankId in static mode', () => {
const config = makeConfig({ bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('my-bank');
});
it('adds prefix in static mode', () => {
const config = makeConfig({ bankIdPrefix: 'dev', bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('dev-my-bank');
});
it('composes from granularity fields in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'project'],
agentName: 'opencode',
});
expect(deriveBankId(config, '/home/user/my-project')).toBe('opencode::my-project');
});
it('uses default granularity when not specified', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: [],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::proj');
});
it('URL-encodes special characters', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['project'],
});
expect(deriveBankId(config, '/home/user/my project')).toBe('my%20project');
});
it('uses channel/user from env vars', () => {
process.env.HINDSIGHT_CHANNEL_ID = 'slack-general';
process.env.HINDSIGHT_USER_ID = 'user123';
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::slack-general::user123');
});
it('uses defaults for missing env vars', () => {
delete process.env.HINDSIGHT_CHANNEL_ID;
delete process.env.HINDSIGHT_USER_ID;
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('default::anonymous');
});
it('adds prefix in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
bankIdPrefix: 'dev',
dynamicBankGranularity: ['agent'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('dev-opencode');
});
});
describe('ensureBankMission', () => {
it('calls createBank on first use', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Test mission' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Test mission',
retainMission: undefined,
});
expect(missionsSet.has('test-bank')).toBe(true);
});
it('skips if already set', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set(['test-bank']);
const config = makeConfig({ bankMission: 'Test mission' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
it('skips if no mission configured', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: '' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
it('does not throw on client error', async () => {
const client = { createBank: vi.fn().mockRejectedValue(new Error('Network error')) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Mission' });
await expect(
ensureBankMission(client, 'test-bank', config, missionsSet),
).resolves.not.toThrow();
expect(missionsSet.has('test-bank')).toBe(false);
});
it('passes retainMission when configured', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Reflect', retainMission: 'Extract carefully' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Reflect',
retainMission: 'Extract carefully',
});
});
});
@@ -0,0 +1,94 @@
/**
* Bank ID derivation and mission management.
*
* Port of Claude Code plugin's bank.py, adapted for OpenCode's context model.
*
* Dimensions for dynamic bank IDs:
* - agent configured name or "opencode"
* - project derived from working directory basename
*/
import { basename } from 'node:path';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import type { HindsightClient } from '@vectorize-io/hindsight-client';
const DEFAULT_BANK_NAME = 'opencode';
const VALID_FIELDS = new Set(['agent', 'project', 'channel', 'user']);
/**
* Derive a bank ID from context and config.
*
* Static mode: returns config.bankId or DEFAULT_BANK_NAME.
* Dynamic mode: composes from granularity fields joined by '::'.
*/
export function deriveBankId(config: HindsightConfig, directory: string): string {
const prefix = config.bankIdPrefix;
if (!config.dynamicBankId) {
const base = config.bankId || DEFAULT_BANK_NAME;
return prefix ? `${prefix}-${base}` : base;
}
const fields = config.dynamicBankGranularity?.length
? config.dynamicBankGranularity
: ['agent', 'project'];
for (const f of fields) {
if (!VALID_FIELDS.has(f)) {
console.error(
`[Hindsight] Unknown dynamicBankGranularity field "${f}" — ` +
`valid: ${[...VALID_FIELDS].sort().join(', ')}`,
);
}
}
const channelId = process.env.HINDSIGHT_CHANNEL_ID || '';
const userId = process.env.HINDSIGHT_USER_ID || '';
const fieldMap: Record<string, string> = {
agent: config.agentName || 'opencode',
project: directory ? basename(directory) : 'unknown',
channel: channelId || 'default',
user: userId || 'anonymous',
};
const segments = fields.map((f) => encodeURIComponent(fieldMap[f] || 'unknown'));
const baseBankId = segments.join('::');
return prefix ? `${prefix}-${baseBankId}` : baseBankId;
}
/**
* Set bank mission on first use, skip if already set.
* Uses an in-memory Set (plugin is long-lived, unlike Claude Code's ephemeral hooks).
*/
export async function ensureBankMission(
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet: Set<string>,
): Promise<void> {
const mission = config.bankMission;
if (!mission?.trim()) return;
if (missionsSet.has(bankId)) return;
try {
await client.createBank(bankId, {
reflectMission: mission,
retainMission: config.retainMission || undefined,
});
missionsSet.add(bankId);
// Cap tracked banks
if (missionsSet.size > 10000) {
const keys = [...missionsSet].sort();
for (const k of keys.slice(0, keys.length >> 1)) {
missionsSet.delete(k);
}
}
debugLog(config, `Set mission for bank: ${bankId}`);
} catch (e) {
// Don't fail if mission set fails — bank may not exist yet
debugLog(config, `Could not set bank mission for ${bankId}: ${e}`);
}
}
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { loadConfig, type HindsightConfig } from './config.js';
describe('loadConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear all HINDSIGHT_ env vars
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) {
delete process.env[key];
}
}
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns defaults when no config sources exist', () => {
const config = loadConfig();
expect(config.autoRecall).toBe(true);
expect(config.autoRetain).toBe(true);
expect(config.recallBudget).toBe('mid');
expect(config.recallMaxTokens).toBe(1024);
expect(config.retainContext).toBe('opencode');
expect(config.agentName).toBe('opencode');
expect(config.dynamicBankId).toBe(false);
expect(config.debug).toBe(false);
expect(config.hindsightApiUrl).toBeNull();
expect(config.hindsightApiToken).toBeNull();
expect(config.bankId).toBeNull();
});
it('env vars override defaults', () => {
process.env.HINDSIGHT_API_URL = 'https://example.com';
process.env.HINDSIGHT_API_TOKEN = 'secret-token';
process.env.HINDSIGHT_BANK_ID = 'my-bank';
process.env.HINDSIGHT_AUTO_RECALL = 'false';
process.env.HINDSIGHT_AUTO_RETAIN = '0';
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '2048';
process.env.HINDSIGHT_DEBUG = 'true';
const config = loadConfig();
expect(config.hindsightApiUrl).toBe('https://example.com');
expect(config.hindsightApiToken).toBe('secret-token');
expect(config.bankId).toBe('my-bank');
expect(config.autoRecall).toBe(false);
expect(config.autoRetain).toBe(false);
expect(config.recallMaxTokens).toBe(2048);
expect(config.debug).toBe(true);
});
it('plugin options override defaults', () => {
const config = loadConfig({
bankId: 'plugin-bank',
autoRecall: false,
recallBudget: 'high',
});
expect(config.bankId).toBe('plugin-bank');
expect(config.autoRecall).toBe(false);
expect(config.recallBudget).toBe('high');
});
it('env vars override plugin options', () => {
process.env.HINDSIGHT_BANK_ID = 'env-bank';
const config = loadConfig({ bankId: 'plugin-bank' });
expect(config.bankId).toBe('env-bank');
});
it('boolean env var parsing', () => {
process.env.HINDSIGHT_AUTO_RECALL = 'true';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = '1';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = 'yes';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = 'false';
expect(loadConfig().autoRecall).toBe(false);
process.env.HINDSIGHT_AUTO_RECALL = 'no';
expect(loadConfig().autoRecall).toBe(false);
});
it('integer env var parsing', () => {
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '4096';
expect(loadConfig().recallMaxTokens).toBe(4096);
// Invalid integer keeps default
process.env.HINDSIGHT_RECALL_MAX_TOKENS = 'not-a-number';
expect(loadConfig().recallMaxTokens).toBe(1024);
});
it('null plugin options are ignored', () => {
const config = loadConfig({ bankId: null, debug: undefined });
expect(config.bankId).toBeNull(); // stays default null
expect(config.debug).toBe(false); // stays default
});
it('invalid retainMode falls back to full-session with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'full_session' });
expect(config.retainMode).toBe('full-session');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown retainMode'));
spy.mockRestore();
});
it('invalid recallBudget falls back to mid with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ recallBudget: 'maximum' });
expect(config.recallBudget).toBe('mid');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown recallBudget'));
spy.mockRestore();
});
it('valid retainMode and recallBudget pass without warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'last-turn', recallBudget: 'high' });
expect(config.retainMode).toBe('last-turn');
expect(config.recallBudget).toBe('high');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
@@ -0,0 +1,187 @@
/**
* Configuration management for the Hindsight OpenCode plugin.
*
* Loading order (later entries win):
* 1. Built-in defaults
* 2. User config file (~/.hindsight/opencode.json)
* 3. Plugin options (from opencode.json plugin tuple)
* 4. Environment variable overrides
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
export interface HindsightConfig {
// Recall
autoRecall: boolean;
recallBudget: string;
recallMaxTokens: number;
recallTypes: string[];
recallContextTurns: number;
recallMaxQueryChars: number;
recallPromptPreamble: string;
// Retain
autoRetain: boolean;
retainMode: string;
retainEveryNTurns: number;
retainOverlapTurns: number;
retainContext: string;
retainTags: string[];
retainMetadata: Record<string, string>;
// Connection
hindsightApiUrl: string | null;
hindsightApiToken: string | null;
// Bank
bankId: string | null;
bankIdPrefix: string;
dynamicBankId: boolean;
dynamicBankGranularity: string[];
bankMission: string;
retainMission: string | null;
agentName: string;
// Misc
debug: boolean;
}
const DEFAULTS: HindsightConfig = {
// Recall
autoRecall: true,
recallBudget: 'mid',
recallMaxTokens: 1024,
recallTypes: ['world', 'experience'],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallPromptPreamble:
'Relevant memories from past conversations (prioritize recent when ' +
'conflicting). Only use memories that are directly useful to continue ' +
'this conversation; ignore the rest:',
// Retain
autoRetain: true,
retainMode: 'full-session',
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: 'opencode',
retainTags: [],
retainMetadata: {},
// Connection
hindsightApiUrl: null,
hindsightApiToken: null,
// Bank
bankId: null,
bankIdPrefix: '',
dynamicBankId: false,
dynamicBankGranularity: ['agent', 'project'],
bankMission: '',
retainMission: null,
agentName: 'opencode',
// Misc
debug: false,
};
/** Env var → config key + type mapping */
const ENV_OVERRIDES: Record<string, [keyof HindsightConfig, 'string' | 'bool' | 'int']> = {
HINDSIGHT_API_URL: ['hindsightApiUrl', 'string'],
HINDSIGHT_API_TOKEN: ['hindsightApiToken', 'string'],
HINDSIGHT_BANK_ID: ['bankId', 'string'],
HINDSIGHT_AGENT_NAME: ['agentName', 'string'],
HINDSIGHT_AUTO_RECALL: ['autoRecall', 'bool'],
HINDSIGHT_AUTO_RETAIN: ['autoRetain', 'bool'],
HINDSIGHT_RETAIN_MODE: ['retainMode', 'string'],
HINDSIGHT_RECALL_BUDGET: ['recallBudget', 'string'],
HINDSIGHT_RECALL_MAX_TOKENS: ['recallMaxTokens', 'int'],
HINDSIGHT_RECALL_MAX_QUERY_CHARS: ['recallMaxQueryChars', 'int'],
HINDSIGHT_RECALL_CONTEXT_TURNS: ['recallContextTurns', 'int'],
HINDSIGHT_DYNAMIC_BANK_ID: ['dynamicBankId', 'bool'],
HINDSIGHT_BANK_MISSION: ['bankMission', 'string'],
HINDSIGHT_DEBUG: ['debug', 'bool'],
};
function castEnv(value: string, typ: 'string' | 'bool' | 'int'): string | boolean | number | null {
if (typ === 'bool') return ['true', '1', 'yes'].includes(value.toLowerCase());
if (typ === 'int') {
const n = parseInt(value, 10);
return isNaN(n) ? null : n;
}
return value;
}
function loadSettingsFile(path: string): Record<string, unknown> {
try {
const raw = readFileSync(path, 'utf-8');
return JSON.parse(raw);
} catch {
return {};
}
}
export function loadConfig(pluginOptions?: Record<string, unknown>): HindsightConfig {
// 1. Start with defaults
const config: Record<string, unknown> = { ...DEFAULTS };
// 2. User config file (~/.hindsight/opencode.json)
const userConfigPath = join(homedir(), '.hindsight', 'opencode.json');
const fileConfig = loadSettingsFile(userConfigPath);
for (const [key, value] of Object.entries(fileConfig)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
// 3. Plugin options (from opencode.json: ["@vectorize-io/opencode-hindsight", { ... }])
if (pluginOptions) {
for (const [key, value] of Object.entries(pluginOptions)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
}
// 4. Environment variable overrides (highest priority)
for (const [envName, [key, typ]] of Object.entries(ENV_OVERRIDES)) {
const val = process.env[envName];
if (val !== undefined) {
const castVal = castEnv(val, typ);
if (castVal !== null) {
config[key] = castVal;
}
}
}
const result = config as unknown as HindsightConfig;
// Validate enum-like fields to catch typos early
const VALID_RETAIN_MODES = ['full-session', 'last-turn'];
if (!VALID_RETAIN_MODES.includes(result.retainMode)) {
console.error(
`[Hindsight] Unknown retainMode "${result.retainMode}" — ` +
`valid: ${VALID_RETAIN_MODES.join(', ')}. Falling back to "full-session".`,
);
result.retainMode = 'full-session';
}
const VALID_BUDGETS = ['low', 'mid', 'high'];
if (!VALID_BUDGETS.includes(result.recallBudget)) {
console.error(
`[Hindsight] Unknown recallBudget "${result.recallBudget}" — ` +
`valid: ${VALID_BUDGETS.join(', ')}. Falling back to "mid".`,
);
result.recallBudget = 'mid';
}
return result;
}
export function debugLog(config: HindsightConfig, ...args: unknown[]): void {
if (config.debug) {
console.error('[Hindsight]', ...args);
}
}
@@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest';
import {
stripMemoryTags,
formatMemories,
formatCurrentTime,
composeRecallQuery,
truncateRecallQuery,
sliceLastTurnsByUserBoundary,
prepareRetentionTranscript,
} from './content.js';
describe('stripMemoryTags', () => {
it('removes <hindsight_memories> blocks', () => {
const input = 'before <hindsight_memories>secret</hindsight_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
it('removes <relevant_memories> blocks', () => {
const input = 'before <relevant_memories>\nmultiline\n</relevant_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
it('removes multiple blocks', () => {
const input = '<hindsight_memories>a</hindsight_memories> middle <relevant_memories>b</relevant_memories>';
expect(stripMemoryTags(input)).toBe(' middle ');
});
it('returns unchanged if no tags', () => {
expect(stripMemoryTags('hello world')).toBe('hello world');
});
});
describe('formatMemories', () => {
it('formats recall results with type and date', () => {
const results = [
{ text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' },
{ text: 'Met at conference', type: 'experience', mentioned_at: '2025-03-15' },
];
const formatted = formatMemories(results);
expect(formatted).toContain('- User likes Python [world] (2025-01-01)');
expect(formatted).toContain('- Met at conference [experience] (2025-03-15)');
});
it('handles missing type and date', () => {
const results = [{ text: 'Some fact' }];
expect(formatMemories(results)).toBe('- Some fact');
});
it('returns empty string for empty array', () => {
expect(formatMemories([])).toBe('');
});
it('separates entries with double newlines', () => {
const results = [{ text: 'A' }, { text: 'B' }];
expect(formatMemories(results)).toBe('- A\n\n- B');
});
});
describe('formatCurrentTime', () => {
it('returns UTC time in YYYY-MM-DD HH:MM format', () => {
const time = formatCurrentTime();
expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
});
describe('composeRecallQuery', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'What is my name?' },
];
it('returns latest query when contextTurns <= 1', () => {
expect(composeRecallQuery('What is my name?', messages, 1)).toBe('What is my name?');
});
it('returns latest query when messages empty', () => {
expect(composeRecallQuery('query', [], 3)).toBe('query');
});
it('includes prior context when contextTurns > 1', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
expect(result).toContain('Prior context:');
expect(result).toContain('user: Hello');
expect(result).toContain('assistant: Hi there');
expect(result).toContain('What is my name?');
});
it('does not duplicate latest query in context', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
// "What is my name?" should appear once at the end, not also as "user: What is my name?"
const matches = result.match(/What is my name\?/g);
expect(matches?.length).toBe(1);
});
});
describe('truncateRecallQuery', () => {
it('returns query unchanged if within limit', () => {
expect(truncateRecallQuery('short', 'short', 100)).toBe('short');
});
it('truncates to latest when no prior context', () => {
const latest = 'my query';
expect(truncateRecallQuery(latest, latest, 5)).toBe('my qu');
});
it('drops oldest context lines first', () => {
const query = 'Prior context:\n\nuser: old\nassistant: older\nuser: recent\n\nlatest';
const result = truncateRecallQuery(query, 'latest', 50);
expect(result).toContain('latest');
// Should have dropped some old context
expect(result.length).toBeLessThanOrEqual(50);
});
});
describe('sliceLastTurnsByUserBoundary', () => {
const messages = [
{ role: 'user', content: 'A' },
{ role: 'assistant', content: 'B' },
{ role: 'user', content: 'C' },
{ role: 'assistant', content: 'D' },
{ role: 'user', content: 'E' },
];
it('returns last N turns', () => {
const result = sliceLastTurnsByUserBoundary(messages, 2);
expect(result.length).toBe(3); // user:C, assistant:D, user:E
expect(result[0].content).toBe('C');
});
it('returns all messages if turns > available', () => {
const result = sliceLastTurnsByUserBoundary(messages, 10);
expect(result.length).toBe(5);
});
it('returns empty for zero turns', () => {
expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]);
});
it('returns empty for empty messages', () => {
expect(sliceLastTurnsByUserBoundary([], 2)).toEqual([]);
});
});
describe('prepareRetentionTranscript', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
{ role: 'assistant', content: 'I am doing well' },
];
it('retains last turn by default', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages);
expect(messageCount).toBe(2);
expect(transcript).toContain('[role: user]');
expect(transcript).toContain('How are you?');
expect(transcript).toContain('I am doing well');
expect(transcript).not.toContain('Hello');
});
it('retains full window when requested', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
expect(messageCount).toBe(4);
expect(transcript).toContain('Hello');
expect(transcript).toContain('How are you?');
});
it('returns null for empty messages', () => {
const { transcript, messageCount } = prepareRetentionTranscript([]);
expect(transcript).toBeNull();
expect(messageCount).toBe(0);
});
it('strips memory tags from content', () => {
const msgs = [
{ role: 'user', content: 'Query <hindsight_memories>data</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript } = prepareRetentionTranscript(msgs);
expect(transcript).not.toContain('hindsight_memories');
expect(transcript).toContain('Query');
});
it('skips messages with empty content after stripping', () => {
const msgs = [
{ role: 'user', content: '<hindsight_memories>only tags</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript, messageCount } = prepareRetentionTranscript(msgs, true);
expect(messageCount).toBe(1); // only assistant message
expect(transcript).toContain('Response');
});
});
@@ -0,0 +1,178 @@
/**
* Content processing utilities.
*
* Port of the Claude Code plugin's content.py:
* - Memory tag stripping (anti-feedback-loop)
* - Recall query composition and truncation
* - Memory formatting for context injection
* - Retention transcript formatting
*/
/** Strip <hindsight_memories> and <relevant_memories> blocks to prevent retain feedback loops. */
export function stripMemoryTags(content: string): string {
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
export interface RecallResult {
text: string;
type?: string | null;
mentioned_at?: string | null;
}
/** Format recall results into human-readable text for context injection. */
export function formatMemories(results: RecallResult[]): string {
if (!results.length) return '';
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : '';
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : '';
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
}
/** Format current UTC time for recall context. */
export function formatCurrentTime(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const h = String(now.getUTCHours()).padStart(2, '0');
const min = String(now.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
export interface Message {
role: string;
content: string;
}
/**
* Compose a multi-turn recall query from conversation history.
*
* When recallContextTurns > 1, includes prior context above the latest query.
*/
export function composeRecallQuery(
latestQuery: string,
messages: Message[],
recallContextTurns: number,
): string {
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !messages.length) return latest;
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines: string[] = [];
for (const msg of contextual) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
if (msg.role === 'user' && content === latest) continue;
contextLines.push(`${msg.role}: ${content}`);
}
if (!contextLines.length) return latest;
return ['Prior context:', contextLines.join('\n'), latest].join('\n\n');
}
/**
* Truncate a composed recall query to maxChars.
* Preserves the latest user message, drops oldest context lines first.
*/
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
if (maxChars <= 0 || query.length <= maxChars) return query;
const latest = latestQuery.trim();
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
if (!query.includes('Prior context:')) return latestOnly;
const contextMarker = 'Prior context:\n\n';
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) return latestOnly;
const suffix = '\n\n' + latest;
const suffixIndex = query.lastIndexOf(suffix);
if (suffixIndex === -1) return latestOnly;
if (suffix.length >= maxChars) return latestOnly;
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split('\n').filter(Boolean);
const kept: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
kept.unshift(contextLines[i]);
const candidate = `${contextMarker}${kept.join('\n')}${suffix}`;
if (candidate.length > maxChars) {
kept.shift();
break;
}
}
if (kept.length) return `${contextMarker}${kept.join('\n')}${suffix}`;
return latestOnly;
}
/** Slice messages to the last N turns, where a turn starts at a user message. */
export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number): Message[] {
if (!messages.length || turns <= 0) return [];
let userTurnsSeen = 0;
let startIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userTurnsSeen++;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
}
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
}
/**
* Format messages into a retention transcript.
*
* Uses [role: ...]...[role:end] markers for structured retention.
*/
export function prepareRetentionTranscript(
messages: Message[],
retainFullWindow: boolean = false,
): { transcript: string | null; messageCount: number } {
if (!messages.length) return { transcript: null, messageCount: 0 };
let targetMessages: Message[];
if (retainFullWindow) {
targetMessages = messages;
} else {
// Default: retain only the last turn
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
targetMessages = messages.slice(lastUserIdx);
}
const parts: string[] = [];
for (const msg of targetMessages) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}
if (!parts.length) return { transcript: null, messageCount: 0 };
const transcript = parts.join('\n\n');
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
return { transcript, messageCount: parts.length };
}
@@ -0,0 +1,357 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHooks, type PluginState } from './hooks.js';
import { makeConfig } from './test-helpers.js';
function makeState(): PluginState {
return {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
}
function makeClient() {
return {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: '' }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
}
function makeOpencodeClient(messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }> = []) {
return {
session: {
messages: vi.fn().mockResolvedValue({ data: messages }),
},
};
}
describe('createHooks', () => {
it('returns all required hooks', () => {
const hooks = createHooks(makeClient(), 'bank', makeConfig(), makeState(), makeOpencodeClient());
expect(hooks.event).toBeDefined();
expect(hooks['experimental.session.compacting']).toBeDefined();
expect(hooks['experimental.chat.system.transform']).toBeDefined();
});
});
describe('event hook — session.idle', () => {
it('auto-retains conversation on session.idle with document_id', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi there' }] },
];
const opencodeClient = makeOpencodeClient(messages);
const state = makeState();
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), state, opencodeClient);
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).toHaveBeenCalledTimes(1);
expect(client.retain.mock.calls[0][0]).toBe('bank');
// Full-session mode uses session ID as document_id
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
});
it('skips retain when autoRetain is false', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRetain: false }),
makeState(),
makeOpencodeClient(messages),
);
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).not.toHaveBeenCalled();
});
it('uses chunked document_id with overlap in last-turn mode', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Turn 1' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Reply 1' }] },
{ role: 'user', parts: [{ type: 'text', text: 'Turn 2' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Reply 2' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1, retainOverlapTurns: 1 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
// Chunked mode uses session-timestamp format
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it('respects retainEveryNTurns', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainEveryNTurns: 5 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
// Only 1 user turn, needs 5 — should not retain
expect(client.retain).not.toHaveBeenCalled();
});
it('does not throw on client error', async () => {
const client = makeClient();
client.retain.mockRejectedValue(new Error('Network error'));
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), makeState(), makeOpencodeClient(messages));
await expect(
hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
}),
).resolves.not.toThrow();
});
});
describe('event hook — session.created', () => {
it('tracks session for recall injection', async () => {
const state = makeState();
const hooks = createHooks(makeClient(), 'bank', makeConfig(), state, makeOpencodeClient());
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1', title: 'Test' } },
},
});
expect(state.recalledSessions.has('sess-1')).toBe(true);
});
it('does not track when autoRecall is false', async () => {
const state = makeState();
const hooks = createHooks(
makeClient(),
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1' } },
},
});
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
});
describe('compacting hook', () => {
it('retains before compaction and recalls context', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'Important fact', type: 'world' }],
});
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Build the feature' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Working on it' }] },
];
const output = { context: [] as string[], prompt: undefined };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
// Should have retained and recalled
expect(client.retain).toHaveBeenCalled();
expect(client.recall).toHaveBeenCalled();
expect(output.context.length).toBeGreaterThan(0);
expect(output.context[0]).toContain('hindsight_memories');
expect(output.context[0]).toContain('Important fact');
});
it('pre-compaction retain includes documentId and session metadata', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
});
it('pre-compaction retain uses chunked documentId in last-turn mode', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1 });
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', config, makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it('does not throw on error', async () => {
const client = makeClient();
client.recall.mockRejectedValue(new Error('Failed'));
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Test' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await expect(
hooks['experimental.session.compacting']({ sessionID: 's' }, output),
).resolves.not.toThrow();
});
});
describe('system transform hook', () => {
it('injects memories for tracked sessions', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'User is a developer', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBeGreaterThan(0);
expect(output.system[0]).toContain('hindsight_memories');
// Session should be removed after first injection
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('skips untracked sessions', async () => {
const client = makeClient();
const state = makeState();
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-unknown', model: {} },
output,
);
expect(output.system.length).toBe(0);
expect(client.recall).not.toHaveBeenCalled();
});
it('consumes session on empty recall (no repeated queries for empty banks)', async () => {
const client = makeClient();
// No results — empty bank
client.recall.mockResolvedValue({ results: [] });
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
// No injection, but session consumed — won't re-query on next transform
expect(output.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('retries recall on next transform after transient API failure', async () => {
const client = makeClient();
// First call: API error (transient)
client.recall.mockRejectedValueOnce(new Error('Connection refused'));
// Second call: succeeds
client.recall.mockResolvedValueOnce({
results: [{ text: 'Found it', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
// First attempt — API error, session preserved for retry
const output1 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output1,
);
expect(output1.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(true);
// Second attempt — succeeds, session consumed
const output2 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output2,
);
expect(output2.system.length).toBeGreaterThan(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('skips when autoRecall is false', async () => {
const client = makeClient();
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBe(0);
});
});
@@ -0,0 +1,308 @@
/**
* Hook implementations for the Hindsight OpenCode plugin.
*
* Hooks:
* - event (session.created) recall memories and inject into system prompt
* - event (session.idle) auto-retain conversation transcript
* - experimental.session.compacting inject memories into compaction context
*/
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import {
formatMemories,
formatCurrentTime,
stripMemoryTags,
composeRecallQuery,
truncateRecallQuery,
prepareRetentionTranscript,
sliceLastTurnsByUserBoundary,
type Message,
} from './content.js';
import { ensureBankMission } from './bank.js';
export interface PluginState {
turnCount: number;
missionsSet: Set<string>;
/** Track sessions we've already injected recall into */
recalledSessions: Set<string>;
/** Track last retained turn count per session to avoid duplicates */
lastRetainedTurn: Map<string, number>;
}
interface EventInput {
event: {
type: string;
properties: Record<string, unknown>;
};
}
interface CompactingInput {
sessionID: string;
}
interface CompactingOutput {
context: string[];
prompt?: string;
}
interface SystemTransformInput {
sessionID?: string;
model: unknown;
}
interface SystemTransformOutput {
system: string[];
}
type OpencodeClient = {
session: {
messages: (opts: { path: { id: string } }) => Promise<{ data?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> }>;
};
};
export interface HindsightHooks {
event: (input: EventInput) => Promise<void>;
'experimental.session.compacting': (
input: CompactingInput,
output: CompactingOutput,
) => Promise<void>;
'experimental.chat.system.transform': (
input: SystemTransformInput,
output: SystemTransformOutput,
) => Promise<void>;
}
export function createHooks(
hindsightClient: HindsightClient,
bankId: string,
config: HindsightConfig,
state: PluginState,
opencodeClient: OpencodeClient,
): HindsightHooks {
interface RecallOutcome {
/** formatted context string, or null if no results */
context: string | null;
/** true if the API call succeeded (even with 0 results) */
ok: boolean;
}
/** Recall memories and format as context string */
async function recallForContext(query: string): Promise<RecallOutcome> {
try {
const response = await hindsightClient.recall(bankId, query, {
budget: config.recallBudget as 'low' | 'mid' | 'high',
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
});
const results = response.results || [];
if (!results.length) return { context: null, ok: true };
const formatted = formatMemories(results);
const context =
`<hindsight_memories>\n` +
`${config.recallPromptPreamble}\n` +
`Current time: ${formatCurrentTime()} UTC\n\n` +
`${formatted}\n` +
`</hindsight_memories>`;
return { context, ok: true };
} catch (e) {
debugLog(config, 'Recall failed:', e);
return { context: null, ok: false };
}
}
/** Extract plain-text messages from an OpenCode session */
async function getSessionMessages(sessionId: string): Promise<Message[]> {
try {
const response = await opencodeClient.session.messages({
path: { id: sessionId },
});
const rawMessages = response.data || [];
const messages: Message[] = [];
for (const msg of rawMessages) {
const role = msg.role;
if (role !== 'user' && role !== 'assistant') continue;
const textParts = (msg.parts || [])
.filter((p: { type: string; text?: string }) => p.type === 'text' && p.text)
.map((p: { type: string; text?: string }) => p.text!);
if (textParts.length) {
messages.push({ role, content: textParts.join('\n') });
}
}
return messages;
} catch (e) {
debugLog(config, 'Failed to get session messages:', e);
return [];
}
}
/**
* Retain messages for a session, respecting retainMode and documentId semantics.
* Used by both idle-retain and pre-compaction retain.
*/
async function retainSession(sessionId: string, messages: Message[]): Promise<void> {
const retainFullWindow = config.retainMode === 'full-session';
let targetMessages: Message[];
let documentId: string;
if (retainFullWindow) {
targetMessages = messages;
// Full-session upserts the same document each time
documentId = sessionId;
} else {
// Sliding window: retainEveryNTurns + overlap
const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns;
targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns);
// Chunked mode: unique document per chunk
documentId = `${sessionId}-${Date.now()}`;
}
const { transcript } = prepareRetentionTranscript(targetMessages, true);
if (!transcript) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
await hindsightClient.retain(bankId, transcript, {
documentId,
context: config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? { ...config.retainMetadata, session_id: sessionId }
: { session_id: sessionId },
async: true,
});
}
/** Auto-retain conversation transcript */
async function handleSessionIdle(sessionId: string): Promise<void> {
if (!config.autoRetain) return;
const messages = await getSessionMessages(sessionId);
if (!messages.length) return;
// Count user turns
const userTurns = messages.filter((m) => m.role === 'user').length;
const lastRetained = state.lastRetainedTurn.get(sessionId) || 0;
// Only retain if enough new turns since last retain
if (userTurns - lastRetained < config.retainEveryNTurns) return;
try {
await retainSession(sessionId, messages);
state.lastRetainedTurn.set(sessionId, userTurns);
debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`);
} catch (e) {
debugLog(config, 'Auto-retain failed:', e);
}
}
const event = async (input: EventInput): Promise<void> => {
try {
const { event: evt } = input;
if (evt.type === 'session.idle') {
const sessionId = (evt.properties as { sessionID?: string }).sessionID;
if (sessionId) {
await handleSessionIdle(sessionId);
}
}
if (evt.type === 'session.created') {
const session = evt.properties.info as { id?: string; title?: string } | undefined;
const sessionId = session?.id;
if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) {
state.recalledSessions.add(sessionId);
// Cap tracked sessions
if (state.recalledSessions.size > 1000) {
const first = state.recalledSessions.values().next().value;
if (first) state.recalledSessions.delete(first);
}
}
}
} catch (e) {
debugLog(config, 'Event hook error:', e);
}
};
const compacting = async (
input: CompactingInput,
output: CompactingOutput,
): Promise<void> => {
try {
// First, retain what we have before compaction (using shared retention logic)
const messages = await getSessionMessages(input.sessionID);
if (messages.length && config.autoRetain) {
try {
await retainSession(input.sessionID, messages);
debugLog(config, 'Pre-compaction retain completed');
} catch (e) {
debugLog(config, 'Pre-compaction retain failed:', e);
}
}
// Then recall relevant memories to inject into compaction context
if (messages.length) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg) {
const query = composeRecallQuery(
lastUserMsg.content,
messages,
config.recallContextTurns,
);
const truncated = truncateRecallQuery(
query,
lastUserMsg.content,
config.recallMaxQueryChars,
);
const { context } = await recallForContext(truncated);
if (context) {
output.context.push(context);
}
}
}
} catch (e) {
debugLog(config, 'Compaction hook error:', e);
}
};
const systemTransform = async (
input: SystemTransformInput,
output: SystemTransformOutput,
): Promise<void> => {
try {
if (!config.autoRecall) return;
const sessionId = input.sessionID;
if (!sessionId) return;
// Only inject on first message of a session (tracked by recalledSessions)
if (!state.recalledSessions.has(sessionId)) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
// Use a generic project-context query for session start
const query = `project context and recent work`;
const { context, ok } = await recallForContext(query);
// Consume after a successful API round-trip (even with 0 results).
// Only preserve retry for transient API failures (ok=false).
if (ok) {
state.recalledSessions.delete(sessionId);
}
if (context) {
output.system.push(context);
debugLog(config, `Injected recall context for session ${sessionId}`);
}
} catch (e) {
debugLog(config, 'System transform hook error:', e);
}
};
return {
event,
'experimental.session.compacting': compacting,
'experimental.chat.system.transform': systemTransform,
};
}

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