Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f978b446d | ||
|
|
a4a9d32480 | ||
|
|
8b4c7eeb8e | ||
|
|
311b96a192 | ||
|
|
fdc48b1544 | ||
|
|
ebb05cf9ca | ||
|
|
66cbdda3cb | ||
|
|
cf4bd598b4 | ||
|
|
443c94c827 | ||
|
|
26794aab09 | ||
|
|
7863ffeb49 | ||
|
|
9e2890ba81 | ||
|
|
e0e65c44f6 | ||
|
|
6881f63781 | ||
|
|
6cb309f72b | ||
|
|
f9fe6953a3 | ||
|
|
07de798c3b | ||
|
|
cefa75545a | ||
|
|
cd99eef4c5 | ||
|
|
cd4b3e96e2 | ||
|
|
e02e7ad3d4 | ||
|
|
98fee1e380 | ||
|
|
906b740dd7 | ||
|
|
7990381f6a |
@@ -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 }}
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
:::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
|
||||
---
|
||||
|
||||

|
||||
|
||||
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
|
||||
---
|
||||
|
||||

|
||||
|
||||
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)
|
||||
+2
@@ -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
|
||||
+71
-37
@@ -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
|
||||
+2
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
+2686
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
Reference in New Issue
Block a user