Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 09d56ec2ea style(retain): format long function call arguments one-per-line 2026-03-26 16:05:22 +01:00
Nicolò Boschi 599a29b01b docs(python-client): improve pydoc strings for async-first usage and low-level API access
- Class docstring now clearly documents async-first pattern: a* methods
  preferred, sync wrappers for scripts/REPLs only
- Every sync method docstring points to its async counterpart
- Every async method docstring says "preferred"
- Expose 10 low-level API properties (documents, entities, operations,
  webhooks, monitoring, etc.) so agents/users can discover the full API
  surface without guessing at _-prefixed internals
- Add missing API parameters: tag_groups (recall/reflect), fact_types,
  exclude_mental_models, exclude_mental_model_ids (reflect),
  observation_scopes/strategy (retain items), background (create_bank)
- Fix areflect missing include_facts param that sync reflect already had
- Sync recall/reflect now delegate to async counterparts (no logic duplication)
2026-03-26 16:00:21 +01:00
Nicolò Boschi c9ff37dcbf fix(python-client): async=true silently ignored on retain (#709)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.

* fix(python-client): async=true was silently ignored on retain calls

The hand-written client wrapper passed `async_=retain_async` to
RetainRequest, but the generated Pydantic model uses `var_async` as the
Python field name (with `alias="async"`). The `async_` kwarg didn't
match either the field name or the alias, so Pydantic silently ignored
it — every retain call ran synchronously regardless of the flag.

This has been broken since the client was first introduced (6073ac4f),
not a regression.

Also adds unit tests that verify the async field serializes correctly
in the request JSON, preventing future regressions.
2026-03-26 15:21:43 +01:00
Nicolò Boschi 91397190c0 docs(claude-code): tidy configuration reference and sync README (#706)
* docs(claude-code): tidy configuration reference and sync README

Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.

* refactor(claude-code): remove recallTopK setting

Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.
2026-03-26 14:07:35 +01:00
Nicolò Boschi fd88c0efa5 feat(retain): delta retain — skip LLM for unchanged chunks on upsert (#701)
* feat(retain): delta retain — skip LLM re-extraction for unchanged chunks on upsert

When upserting a document (same document_id), instead of deleting all
facts and re-extracting from scratch, compare chunk content hashes
and only process changed/new chunks. Unchanged chunks keep their
existing facts, entities, and links.

- Add content_hash column to chunks table (migration b3c4d5e6f7a8)
- Add chunk delta comparison functions in chunk_storage.py
- Add delta_mode to fact_storage.handle_document_tracking (skip full delete)
- Add update_memory_units_tags for propagating tag changes to existing facts
- Refactor orchestrator into _try_delta_retain and _full_retain paths
- Automatic fallback to full retain for pre-migration data or all-changed scenarios
- Fix ty type error in metrics.py (resource module import on Windows)
- 16 new tests covering entities, links, tags, metadata, edge cases

* refactor(retain): deduplicate delta and full retain paths

Extract shared _insert_facts_and_links() and _extract_and_embed()
functions used by both the full retain and delta retain paths.
Remove delta_mode flag from handle_document_tracking — delta path
uses dedicated upsert_document_metadata() instead.

* chore: regenerate clients, openapi spec, and lockfile

* chore: regenerate docs skill
2026-03-26 13:50:55 +01:00
Nicolò Boschi ea4df8dbb5 fix: resolve remaining Dependabot security alerts (#705)
- python-multipart: pin >=0.0.22 (arbitrary file write via non-default config)
- requests: pin >=2.33.0 in litellm, langgraph, crewai integrations (insecure temp file reuse)

Remaining unfixable alerts: diskcache (<=5.6.3, no patch) and Pygments (<=2.19.2, no patch).
2026-03-26 13:43:27 +01:00
23 changed files with 2208 additions and 572 deletions
@@ -0,0 +1,32 @@
"""add content_hash to chunks table for delta retain
Revision ID: b3c4d5e6f7a8
Revises: a3b4c5d6e7f8
Create Date: 2026-03-25
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b3c4d5e6f7a8"
down_revision: str | Sequence[str] | None = "a3b4c5d6e7f8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add content_hash column to chunks table for delta comparison
op.execute(f"ALTER TABLE {schema}chunks ADD COLUMN IF NOT EXISTS content_hash TEXT")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}chunks DROP COLUMN IF EXISTS content_hash")
@@ -4,7 +4,9 @@ Chunk storage for retain pipeline.
Handles storage of document chunks in the database.
"""
import hashlib
import logging
from dataclasses import dataclass
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -12,6 +14,61 @@ from .types import ChunkMetadata
logger = logging.getLogger(__name__)
def compute_chunk_hash(chunk_text: str) -> str:
"""Compute SHA256 hash of chunk text for delta comparison."""
return hashlib.sha256(chunk_text.encode()).hexdigest()
@dataclass
class ExistingChunk:
"""Represents a chunk already stored in the database."""
chunk_id: str
chunk_index: int
content_hash: str | None
async def load_existing_chunks(conn, bank_id: str, document_id: str) -> list[ExistingChunk]:
"""
Load existing chunk metadata for a document.
Returns list of ExistingChunk with chunk_id, chunk_index, and content_hash.
"""
rows = await conn.fetch(
f"""
SELECT chunk_id, chunk_index, content_hash
FROM {fq_table("chunks")}
WHERE document_id = $1 AND bank_id = $2
ORDER BY chunk_index
""",
document_id,
bank_id,
)
return [
ExistingChunk(
chunk_id=row["chunk_id"],
chunk_index=row["chunk_index"],
content_hash=row["content_hash"],
)
for row in rows
]
async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
Delete specific chunks by their IDs.
This cascades to memory_units (via FK with CASCADE delete)
and their links.
"""
if not chunk_ids:
return
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
chunk_ids,
)
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
"""
Store document chunks in the database.
@@ -32,6 +89,7 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids = []
chunk_texts = []
chunk_indices = []
content_hashes = []
chunk_id_map = {}
for chunk in chunks:
@@ -39,19 +97,21 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text)
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
# Batch insert all chunks
await conn.execute(
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index, content_hash)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[], $6::text[])
""",
chunk_ids,
[document_id] * len(chunk_texts),
[bank_id] * len(chunk_texts),
chunk_texts,
chunk_indices,
content_hashes,
)
return chunk_id_map
@@ -221,7 +221,10 @@ async def handle_document_tracking(
document_tags: list[str] | None = None,
) -> None:
"""
Handle document tracking in the database.
Handle document tracking in the database (full-replace mode).
Deletes the existing document (cascading to all units and links) on the
first batch, then inserts the new document record.
Args:
conn: Database connection
@@ -238,14 +241,51 @@ async def handle_document_tracking(
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
# Always delete old document first if it exists (cascades to units and links)
# Delete old document first (cascades to units and links)
# Only delete on the first batch to avoid deleting data we just inserted
if is_first_batch:
await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id,
bank_id,
)
# Insert document (or update if exists from concurrent operations)
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def upsert_document_metadata(
conn,
bank_id: str,
document_id: str,
combined_content: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""
Update document metadata without deleting existing facts/chunks.
Used by delta retain: the document row is upserted but chunks and
memory_units are managed separately at the chunk level.
"""
import hashlib
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
async def _upsert_document_row(
conn,
bank_id: str,
document_id: str,
combined_content: str,
content_hash: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
) -> None:
"""Insert or update a document row."""
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params, tags)
@@ -266,3 +306,34 @@ async def handle_document_tracking(
json.dumps(retain_params) if retain_params else None,
document_tags or [],
)
async def update_memory_units_tags(
conn,
bank_id: str,
document_id: str,
tags: list[str],
) -> int:
"""
Update tags on all memory_units belonging to a document.
Used during delta retain to propagate tag changes to unchanged facts.
Returns:
Number of memory units updated.
"""
result = await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET tags = $3, updated_at = NOW()
WHERE bank_id = $1 AND document_id = $2
""",
bank_id,
document_id,
tags or [],
)
# result is a status string like "UPDATE 5"
try:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -11,14 +11,11 @@ This module provides metrics for:
- Database connection pool metrics
"""
import importlib
import logging
import os
import types
try:
import resource
except ImportError:
resource: types.ModuleType | None = None # Windows doesn't have resource module
_resource_mod = importlib.import_module("resource") if importlib.util.find_spec("resource") else None
import threading
import time
from contextlib import contextmanager
@@ -460,13 +457,13 @@ class MetricsCollector(MetricsCollectorBase):
def _setup_process_metrics(self):
"""Set up observable gauges for process metrics."""
if resource is None:
if _resource_mod is None:
return # Skip process metrics on Windows
def get_cpu_times(_options):
"""Get process CPU times."""
try:
rusage = resource.getrusage(resource.RUSAGE_SELF)
rusage = _resource_mod.getrusage(_resource_mod.RUSAGE_SELF)
yield metrics.Observation(rusage.ru_utime, {"type": "user"})
yield metrics.Observation(rusage.ru_stime, {"type": "system"})
except Exception:
@@ -475,7 +472,7 @@ class MetricsCollector(MetricsCollectorBase):
def get_memory_usage(_options):
"""Get process memory usage in bytes."""
try:
rusage = resource.getrusage(resource.RUSAGE_SELF)
rusage = _resource_mod.getrusage(_resource_mod.RUSAGE_SELF)
# ru_maxrss is in kilobytes on Linux, bytes on macOS
max_rss = rusage.ru_maxrss
if os.uname().sysname == "Linux":
@@ -493,7 +490,7 @@ class MetricsCollector(MetricsCollectorBase):
yield metrics.Observation(count)
else:
# Fallback: use resource limits
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
soft, hard = _resource_mod.getrlimit(_resource_mod.RLIMIT_NOFILE)
yield metrics.Observation(soft, {"limit": "soft"})
except Exception:
pass
+1
View File
@@ -57,6 +57,7 @@ dependencies = [
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
"orjson>=3.11.6", # Unbounded recursion DoS fix
"python-multipart>=0.0.22", # Arbitrary file write via non-default configuration fix
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27",
@@ -0,0 +1,842 @@
"""
Tests for delta retain — upsert optimization that only re-processes changed chunks.
"""
import logging
from datetime import datetime, timezone
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
def _ts():
return datetime.now(timezone.utc).timestamp()
# ============================================================
# Core Delta Retain Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_unchanged_content_skips_llm(memory, request_context):
"""
When upserting a document with identical content, no new facts should be
extracted (LLM is not called for unchanged chunks). The existing facts
should be preserved.
"""
bank_id = f"test_delta_unchanged_{_ts()}"
document_id = "conversation-001"
try:
content = "Alice works at Google. Bob works at Microsoft."
# First retain — full processing
v1_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team info",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0, "v1 should create facts"
# Get v1 document state
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
v1_unit_count = doc_v1["memory_unit_count"]
# Second retain — same content, should use delta path (no new facts)
v2_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team info",
document_id=document_id,
request_context=request_context,
)
# No new units should be returned (nothing changed)
assert v2_units == [], "Delta retain with unchanged content should return empty unit list"
# Existing facts should still be there
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2["memory_unit_count"] == v1_unit_count, "Existing facts should be preserved"
# Verify recall still works
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result.results) > 0, "Should still recall facts after delta retain"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_appended_content(memory, request_context):
"""
When a conversation grows (new content appended), only new chunks should
be processed. Facts from unchanged chunks should be preserved.
"""
bank_id = f"test_delta_append_{_ts()}"
document_id = "growing-conversation"
try:
# First version — short content (single chunk)
v1_content = "Alice is a software engineer at Google. She works on search infrastructure."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="profile",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Get v1 facts via recall
v1_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
v1_fact_texts = {r.text for r in v1_recall.results}
# Second version — original content + new content appended
# This should preserve facts from the first chunk and add new ones
v2_content = v1_content + "\n\nBob joined Google as a product manager in 2024. He previously worked at Meta on AR/VR products."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Should have facts about Bob from the new content
v2_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Bob do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
bob_facts = [r for r in v2_recall.results if "bob" in r.text.lower()]
assert len(bob_facts) > 0, "Should have facts about Bob from appended content"
# Should still have facts about Alice from original content
alice_recall = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
assert len(alice_recall.results) > 0, "Should still have Alice facts from original content"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_modified_chunk(memory, request_context):
"""
When content in the middle changes, that chunk should be re-processed
while other chunks are preserved.
"""
bank_id = f"test_delta_modified_{_ts()}"
document_id = "changing-doc"
try:
# v1: Alice works at Google
v1_content = "Alice works at Google as a senior engineer."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# v2: Alice works at Microsoft (changed)
v2_content = "Alice works at Microsoft as a principal engineer."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="team",
document_id=document_id,
request_context=request_context,
)
# New facts should reflect the updated content
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "microsoft" in all_texts, f"Should have updated fact about Microsoft, got: {all_texts}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Entity & Link Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_entities_preserved_for_unchanged_chunks(memory, request_context):
"""
Entities linked to unchanged chunks should be preserved after delta retain.
"""
bank_id = f"test_delta_entities_{_ts()}"
document_id = "entity-doc"
try:
v1_content = "Alice works at Google. She is a senior engineer in the Cloud division."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Check entities exist
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
assert len(v1_entity_names) > 0, "Should have entities after v1 retain"
# Upsert with same content — entities should persist
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
# All v1 entities should still exist
assert v1_entity_names.issubset(v2_entity_names), (
f"v1 entities {v1_entity_names} should be preserved, got {v2_entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_new_entities_created_for_new_chunks(memory, request_context):
"""
New entities should be created for newly added chunks during delta retain.
"""
bank_id = f"test_delta_new_entities_{_ts()}"
document_id = "entity-growth-doc"
try:
v1_content = "Alice works at Google."
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="team",
document_id=document_id,
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
# Append content mentioning new entities
v2_content = v1_content + "\n\nBob joined Facebook. He works with Charlie on the Reality Labs project."
await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="team",
document_id=document_id,
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
# Should have more entities after adding content with new people/orgs
assert len(v2_entity_names) > len(v1_entity_names), (
f"Should have more entities after append: v1={v1_entity_names}, v2={v2_entity_names}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_links_preserved_for_unchanged_chunks(memory, request_context):
"""
Memory links (temporal, semantic, entity) for unchanged chunks should be preserved.
"""
bank_id = f"test_delta_links_{_ts()}"
document_id = "links-doc"
try:
content = "Alice is a senior engineer at Google Cloud. She mentors junior engineers and reviews their code."
v1_units = await memory.retain_async(
bank_id=bank_id,
content=content,
context="team",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
# Count links after v1
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_link_count = await conn.fetchval(
"""SELECT COUNT(*) FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1 AND mu.document_id = $2""",
bank_id,
document_id,
)
# Upsert with same content
await memory.retain_async(
bank_id=bank_id,
content=content,
context="team",
document_id=document_id,
request_context=request_context,
)
# Links should be preserved
async with pool.acquire() as conn:
v2_link_count = await conn.fetchval(
"""SELECT COUNT(*) FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1 AND mu.document_id = $2""",
bank_id,
document_id,
)
assert v2_link_count == v1_link_count, (
f"Links should be preserved: v1={v1_link_count}, v2={v2_link_count}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Document Metadata & Tags Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_document_metadata_updated(memory, request_context):
"""
Document metadata (retain_params, tags) should be updated even when
chunk content hasn't changed.
"""
bank_id = f"test_delta_meta_{_ts()}"
document_id = "metadata-doc"
try:
content = "Alice works at Google."
# v1 with initial tags
await memory.retain_async(
bank_id=bank_id,
content=content,
context="initial context",
document_id=document_id,
request_context=request_context,
)
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v1 is not None
# v2 with updated context (same content — triggers delta path)
await memory.retain_async(
bank_id=bank_id,
content=content,
context="updated context",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
assert doc_v2["updated_at"] >= doc_v1["updated_at"], "Document should have updated timestamp"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_tags_propagated_to_existing_units(memory, request_context):
"""
When tags change during an upsert with unchanged content, the new tags
should be propagated to all existing memory units.
"""
bank_id = f"test_delta_tags_{_ts()}"
document_id = "tags-doc"
try:
content = "Alice works at Google."
# v1 with tag "team-a"
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"tags": ["team-a"],
}],
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
assert all("team-a" in row["tags"] for row in v1_tags), "v1 units should have team-a tag"
# v2 with same content but different tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"tags": ["team-b", "important"],
}],
request_context=request_context,
)
async with pool.acquire() as conn:
v2_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
for row in v2_tags:
assert "team-b" in row["tags"], f"v2 units should have team-b tag, got {row['tags']}"
assert "important" in row["tags"], f"v2 units should have important tag, got {row['tags']}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Chunk Management Tests
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_removed_chunks_delete_facts(memory, request_context):
"""
When content is shortened (chunks removed), facts from the removed
chunks should be deleted.
"""
bank_id = f"test_delta_removed_{_ts()}"
document_id = "shrinking-doc"
try:
# v1: longer content with facts about Alice and Bob
v1_content = (
"Alice is a senior engineer at Google Cloud. "
"She leads the infrastructure team and has been there for 5 years.\n\n"
"Bob is a product manager at Facebook Reality Labs. "
"He previously worked at Amazon on Alexa voice products."
)
v1_units = await memory.retain_async(
bank_id=bank_id,
content=v1_content,
context="profiles",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
v1_count = doc_v1["memory_unit_count"]
# v2: Completely different content — all chunks change
v2_content = "Charlie works at Netflix as a data scientist."
v2_units = await memory.retain_async(
bank_id=bank_id,
content=v2_content,
context="profiles",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
# Should have facts about Charlie
result = await memory.recall_async(
bank_id=bank_id,
query="Who works at Netflix?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "charlie" in all_texts or "netflix" in all_texts, (
f"Should have facts about Charlie/Netflix after replacing content, got: {all_texts}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_chunks_have_content_hash(memory, request_context):
"""
After retain, chunks should have content_hash populated.
"""
bank_id = f"test_delta_hash_{_ts()}"
document_id = "hash-doc"
try:
content = "Alice works at Google as a software engineer."
await memory.retain_async(
bank_id=bank_id,
content=content,
document_id=document_id,
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
chunks = await conn.fetch(
"SELECT chunk_id, content_hash FROM chunks WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert len(chunks) > 0, "Should have stored chunks"
for chunk in chunks:
assert chunk["content_hash"] is not None, f"Chunk {chunk['chunk_id']} should have content_hash"
assert len(chunk["content_hash"]) == 64, "content_hash should be SHA256 hex (64 chars)"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Backward Compatibility Tests
# ============================================================
@pytest.mark.asyncio
async def test_retain_without_document_id_still_works(memory, request_context):
"""
Retain without document_id should still work normally (no delta path).
"""
bank_id = f"test_no_docid_{_ts()}"
try:
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="test",
request_context=request_context,
)
assert len(units) > 0, "Should create facts without document_id"
result = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result.results) > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_first_retain_full_path(memory, request_context):
"""
First retain of a new document should use the full path (no delta possible).
"""
bank_id = f"test_first_retain_{_ts()}"
document_id = "new-doc"
try:
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="test",
document_id=document_id,
request_context=request_context,
)
assert len(units) > 0, "First retain should create facts via full path"
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ============================================================
# Edge Cases
# ============================================================
@pytest.mark.asyncio
async def test_delta_retain_empty_to_content(memory, request_context):
"""
Going from gibberish (zero facts) to real content should work.
"""
bank_id = f"test_delta_empty_{_ts()}"
document_id = "empty-to-content"
try:
# v1: content that probably produces zero facts
await memory.retain_async(
bank_id=bank_id,
content="!!!###$$$%%%",
document_id=document_id,
request_context=request_context,
)
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v1 is not None
# v2: real content
v2_units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior engineer.",
document_id=document_id,
request_context=request_context,
)
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_v2 is not None
assert doc_v2["memory_unit_count"] > 0 or len(v2_units) > 0, "Should have facts after updating with real content"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_multiple_upserts(memory, request_context):
"""
Multiple sequential upserts should work correctly, with delta optimization
kicking in after the first retain.
"""
bank_id = f"test_delta_multi_{_ts()}"
document_id = "multi-upsert"
try:
# v1: initial
v1_content = "Alice works at Google."
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
document_id=document_id,
request_context=request_context,
)
# v2: same content (delta: no changes)
await memory.retain_async(
bank_id=bank_id,
content=v1_content,
document_id=document_id,
request_context=request_context,
)
# v3: append
v3_content = v1_content + "\n\nBob works at Microsoft."
await memory.retain_async(
bank_id=bank_id,
content=v3_content,
document_id=document_id,
request_context=request_context,
)
# v4: same as v3 (delta: no changes again)
await memory.retain_async(
bank_id=bank_id,
content=v3_content,
document_id=document_id,
request_context=request_context,
)
# Final check: should have facts about both Alice and Bob
result = await memory.recall_async(
bank_id=bank_id,
query="Who works where?",
budget=Budget.MID,
max_tokens=2000,
request_context=request_context,
)
all_texts = " ".join(r.text.lower() for r in result.results)
assert "alice" in all_texts or "google" in all_texts, f"Should have Alice/Google facts, got: {all_texts}"
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_with_user_entities(memory, request_context):
"""
User-provided entities should work correctly with delta retain.
"""
bank_id = f"test_delta_user_entities_{_ts()}"
document_id = "user-entity-doc"
try:
content = "The project is going well."
# v1 with user entities
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": content,
"document_id": document_id,
"entities": [{"text": "Project Alpha", "type": "PROJECT"}],
}],
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_names = {e["canonical_name"].lower() for e in v1_entities}
# v2 with additional entity, same content
# Note: same content = delta path (no re-extraction)
# The user entities for NEW chunks only get processed
v2_content = content + "\n\nThe timeline is on track for Q2 delivery."
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{
"content": v2_content,
"document_id": document_id,
"entities": [
{"text": "Project Alpha", "type": "PROJECT"},
{"text": "Q2 Deadline", "type": "MILESTONE"},
],
}],
request_context=request_context,
)
# Should have entities from both v1 and v2
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_names = {e["canonical_name"].lower() for e in v2_entities}
# v1 entities should be preserved
assert v1_names.issubset(v2_names), f"v1 entities should be preserved: {v1_names} not in {v2_names}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_recall_with_chunks(memory, request_context):
"""
After delta retain, recall with include_chunks should return correct chunk data.
"""
bank_id = f"test_delta_recall_chunks_{_ts()}"
document_id = "recall-chunks-doc"
try:
content = "Alice is a senior engineer at Google Cloud. She designs distributed systems."
await memory.retain_async(
bank_id=bank_id,
content=content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Upsert with same content (delta: no changes)
await memory.retain_async(
bank_id=bank_id,
content=content,
context="profile",
document_id=document_id,
request_context=request_context,
)
# Recall with chunks
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.MID,
max_tokens=2000,
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
)
assert len(result.results) > 0, "Should recall facts"
# Facts with chunk_ids should have corresponding chunks
facts_with_chunks = [r for r in result.results if r.chunk_id]
if facts_with_chunks and result.chunks:
for fact in facts_with_chunks:
assert fact.chunk_id in result.chunks, (
f"Chunk {fact.chunk_id} should be in returned chunks"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -1,18 +1,24 @@
"""
Hindsight Client - Clean, pythonic wrapper for the Hindsight API.
This package provides a high-level interface for common Hindsight operations.
For advanced use cases, use the auto-generated API client directly.
This package provides a high-level ``Hindsight`` class with simplified methods
for the most common operations (retain, recall, reflect, banks, mental models,
directives).
For operations not available as convenience methods — such as documents,
entities, async operations, webhooks, and monitoring — use the low-level API
clients exposed as properties on the ``Hindsight`` instance (e.g.
``client.documents``, ``client.entities``, ``client.operations``).
All low-level methods are async.
Quick start::
Example:
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Store a memory
result = client.retain(bank_id="alice", content="Alice loves AI")
print(result.success)
client.retain(bank_id="alice", content="Alice loves AI")
# Search memories
response = client.recall(bank_id="alice", query="What does Alice like?")
@@ -22,7 +28,19 @@ Example:
# Generate contextual answer
answer = client.reflect(bank_id="alice", query="What are my interests?")
print(answer.text)
```
Low-level API access::
import asyncio
# List documents
docs = asyncio.run(client.documents.list_documents("alice"))
# Check operation status
status = asyncio.run(client.operations.get_operation_status("alice", "op-id"))
# List entities
entities = asyncio.run(client.entities.list_entities("alice"))
"""
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
@@ -12,7 +12,18 @@ from pathlib import Path
from typing import Any, Literal
import hindsight_client_api
from hindsight_client_api.api import banks_api, directives_api, files_api, memory_api, mental_models_api
from hindsight_client_api.api import (
banks_api,
directives_api,
documents_api,
entities_api,
files_api,
memory_api,
mental_models_api,
monitoring_api,
operations_api,
webhooks_api,
)
from hindsight_client_api.models import (
memory_item,
recall_request,
@@ -44,27 +55,68 @@ class Hindsight:
"""
High-level, easy-to-use Hindsight API client.
Example:
```python
This class provides simplified methods for the most common operations:
retain, recall, reflect, bank management, mental models, and directives.
**Async vs sync:** Every convenience method has an async counterpart
prefixed with ``a`` (e.g. ``aretain``, ``arecall``, ``areflect``).
**Prefer the async variants** (``aretain``, ``arecall``, ``areflect``, etc.)
whenever you are inside an async context (``async def``, event loops,
frameworks like FastAPI/LangGraph/CrewAI). The sync versions (``retain``,
``recall``, ``reflect``) are convenience wrappers that call
``asyncio.run_until_complete`` under the hood — they exist for scripts and
REPLs but will raise errors if an event loop is already running.
For operations not covered here (documents, entities, operations/async jobs,
webhooks, file uploads, monitoring), use the low-level API clients exposed
as properties on this class. These are auto-generated from the OpenAPI spec
and cover the full API surface. **All low-level methods are async-only.**
Example — async (preferred)::
from hindsight_client import Hindsight
# Without authentication
client = Hindsight(base_url="http://localhost:8888")
# With API key authentication
client = Hindsight(base_url="http://localhost:8888", api_key="your-api-key")
# Store a memory
# Inside an async function — use the a* methods
await client.aretain(bank_id="alice", content="Alice loves AI")
response = await client.arecall(bank_id="alice", query="What does Alice like?")
answer = await client.areflect(bank_id="alice", query="What are my interests?")
Example — sync (scripts / REPLs only)::
# Outside an async context — sync wrappers are available
client.retain(bank_id="alice", content="Alice loves AI")
# Recall memories
response = client.recall(bank_id="alice", query="What does Alice like?")
for r in response.results:
print(r.text)
# Generate contextual answer
answer = client.reflect(bank_id="alice", query="What are my interests?")
```
Example — low-level API for advanced operations::
# Access documents, entities, operations, webhooks, etc.
# All low-level methods are async-only — use 'await' or asyncio.run().
# List documents in a bank
docs = await client.documents.list_documents("alice")
# Delete a specific document
await client.documents.delete_document("alice", "doc-123")
# Check async operation status
status = await client.operations.get_operation_status("alice", "op-456")
# List entities
entities = await client.entities.list_entities("alice")
Available low-level API properties:
- ``client.memory``: Core memory operations (MemoryApi)
- ``client.banks``: Bank management (BanksApi)
- ``client.documents``: Document CRUD (DocumentsApi)
- ``client.entities``: Entity browsing (EntitiesApi)
- ``client.mental_models``: Mental model management (MentalModelsApi)
- ``client.directives``: Directive management (DirectivesApi)
- ``client.operations``: Async operation tracking (OperationsApi)
- ``client.webhooks``: Webhook management (WebhooksApi)
- ``client.files``: File upload (FilesApi)
- ``client.monitoring``: Health/version checks (MonitoringApi)
"""
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 300.0):
@@ -88,6 +140,66 @@ class Hindsight:
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
self._directives_api = directives_api.DirectivesApi(self._api_client)
self._files_api = files_api.FilesApi(self._api_client)
self._documents_api = documents_api.DocumentsApi(self._api_client)
self._entities_api = entities_api.EntitiesApi(self._api_client)
self._operations_api = operations_api.OperationsApi(self._api_client)
self._webhooks_api = webhooks_api.WebhooksApi(self._api_client)
self._monitoring_api = monitoring_api.MonitoringApi(self._api_client)
# -- Low-level API accessors ------------------------------------------------
# These expose the full, auto-generated API surface for operations not
# covered by the convenience methods above. All methods on these objects
# are async — use ``await`` or ``asyncio.run()`` to call them.
@property
def memory(self) -> memory_api.MemoryApi:
"""Low-level Memory API — retain, recall, reflect, list/clear memories, tags, and graph."""
return self._memory_api
@property
def banks(self) -> banks_api.BanksApi:
"""Low-level Banks API — create, update, delete banks; stats; consolidation; config."""
return self._banks_api
@property
def documents(self) -> documents_api.DocumentsApi:
"""Low-level Documents API — list, get, update, delete documents and chunks."""
return self._documents_api
@property
def entities(self) -> entities_api.EntitiesApi:
"""Low-level Entities API — list, get, and regenerate entity observations."""
return self._entities_api
@property
def mental_models(self) -> mental_models_api.MentalModelsApi:
"""Low-level Mental Models API — create, list, get, update, refresh, delete, history."""
return self._mental_models_api
@property
def directives(self) -> directives_api.DirectivesApi:
"""Low-level Directives API — create, list, get, update, delete."""
return self._directives_api
@property
def operations(self) -> operations_api.OperationsApi:
"""Low-level Operations API — get status, list, cancel, retry async operations."""
return self._operations_api
@property
def webhooks(self) -> webhooks_api.WebhooksApi:
"""Low-level Webhooks API — create, list, update, delete webhooks and deliveries."""
return self._webhooks_api
@property
def files(self) -> files_api.FilesApi:
"""Low-level Files API — upload and retain files."""
return self._files_api
@property
def monitoring(self) -> monitoring_api.MonitoringApi:
"""Low-level Monitoring API — health check, version, metrics."""
return self._monitoring_api
def __enter__(self):
"""Context manager entry."""
@@ -128,7 +240,7 @@ class Hindsight:
tags: list[str] | None = None,
) -> RetainResponse:
"""
Store a single memory (simplified interface).
Store a single memory (sync wrapper — prefer :meth:`aretain` in async code).
Args:
bank_id: The memory bank ID
@@ -167,11 +279,13 @@ class Hindsight:
retain_async: bool = False,
) -> RetainResponse:
"""
Store multiple memories in batch.
Store multiple memories in batch (sync wrapper — prefer :meth:`aretain_batch` in async code).
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
items: List of memory items, each a dict with 'content' (required) and optional keys:
'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags',
'observation_scopes' (str or list[list[str]]), 'strategy'.
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
@@ -179,37 +293,16 @@ class Hindsight:
Returns:
RetainResponse with success status and item count
"""
from hindsight_client_api.models.entity_input import EntityInput
from hindsight_client_api.models.timestamp import Timestamp
memory_items = []
for item in items:
entities = None
if item.get("entities"):
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
raw_ts = item.get("timestamp")
timestamp_val = Timestamp(actual_instance=raw_ts) if raw_ts is not None else None
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
timestamp=timestamp_val,
context=item.get("context"),
metadata=item.get("metadata"),
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
entities=entities,
tags=item.get("tags"),
)
return _run_async(
self.aretain_batch(
bank_id=bank_id,
items=items,
document_id=document_id,
document_tags=document_tags,
retain_async=retain_async,
)
request_obj = retain_request.RetainRequest(
items=memory_items,
async_=retain_async,
document_tags=document_tags,
)
return _run_async(self._memory_api.retain_memories(bank_id, request_obj, _request_timeout=self._timeout))
def retain_files(
self,
bank_id: str,
@@ -218,7 +311,7 @@ class Hindsight:
files_metadata: list[dict[str, Any]] | None = None,
) -> FileRetainResponse:
"""
Upload files and retain their contents as memories.
Upload files and retain their contents as memories (sync wrapper).
Files are automatically converted to text (PDF, DOCX, images via OCR, audio via
transcription, and more) and ingested as memories. Processing is always asynchronous
@@ -262,9 +355,10 @@ class Hindsight:
max_source_facts_tokens: int = 4096,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
tag_groups: list[dict[str, Any]] | None = None,
) -> RecallResponse:
"""
Recall memories using semantic similarity.
Recall memories using semantic similarity (sync wrapper — prefer :meth:`arecall` in async code).
Args:
bank_id: The memory bank ID
@@ -283,41 +377,32 @@ class Hindsight:
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
tag_groups: Optional list of tag group filters for advanced boolean tag matching.
Returns:
RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace
"""
from hindsight_client_api.models import (
chunk_include_options,
entity_include_options,
include_options,
source_facts_include_options,
return _run_async(
self.arecall(
bank_id=bank_id,
query=query,
types=types,
max_tokens=max_tokens,
budget=budget,
trace=trace,
query_timestamp=query_timestamp,
include_entities=include_entities,
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
)
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens)
if include_entities
else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
source_facts=source_facts_include_options.SourceFactsIncludeOptions(max_tokens=max_source_facts_tokens)
if include_source_facts
else None,
)
request_obj = recall_request.RecallRequest(
query=query,
types=types,
budget=budget,
max_tokens=max_tokens,
trace=trace,
query_timestamp=query_timestamp,
include=include_opts,
tags=tags,
tags_match=tags_match,
)
return _run_async(self._memory_api.recall_memories(bank_id, request_obj, _request_timeout=self._timeout))
def reflect(
self,
bank_id: str,
@@ -329,9 +414,13 @@ class Hindsight:
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
include_facts: bool = False,
tag_groups: list[dict[str, Any]] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
exclude_mental_model_ids: list[str] | None = None,
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories.
Generate a contextual answer based on bank identity and memories (sync wrapper — prefer :meth:`areflect` in async code).
Args:
bank_id: The memory bank ID
@@ -347,25 +436,33 @@ class Hindsight:
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
include_facts: If True, the response will include a 'based_on' field listing
the memories, mental models, and directives used to construct the answer.
tag_groups: Optional list of tag group filters for advanced boolean tag matching.
fact_types: Optional list of fact types to include (world, experience, observation).
exclude_mental_models: If True, exclude all mental models from reflection (default: False).
exclude_mental_model_ids: Optional list of specific mental model IDs to exclude.
Returns:
ReflectResponse with answer text, optionally facts used, and optionally
structured_output if response_schema was provided
"""
include = ReflectIncludeOptions(facts={}) if include_facts else None
request_obj = reflect_request.ReflectRequest(
query=query,
budget=budget,
context=context,
max_tokens=max_tokens,
response_schema=response_schema,
tags=tags,
tags_match=tags_match,
include=include,
return _run_async(
self.areflect(
bank_id=bank_id,
query=query,
budget=budget,
context=context,
max_tokens=max_tokens,
response_schema=response_schema,
tags=tags,
tags_match=tags_match,
include_facts=include_facts,
tag_groups=tag_groups,
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=exclude_mental_model_ids,
)
)
return _run_async(self._memory_api.reflect(bank_id, request_obj, _request_timeout=self._timeout))
def list_memories(
self,
bank_id: str,
@@ -374,7 +471,7 @@ class Hindsight:
limit: int = 100,
offset: int = 0,
) -> ListMemoryUnitsResponse:
"""List memory units with pagination."""
"""List memory units with pagination (sync wrapper — use ``await client.memory.list_memories(...)`` in async code)."""
return _run_async(
self._memory_api.list_memories(
bank_id=bank_id,
@@ -402,8 +499,9 @@ class Hindsight:
enable_observations: bool | None = None,
observations_mission: str | None = None,
reflect_mission: str | None = None,
background: str | None = None,
) -> BankProfileResponse:
"""Create or update a memory bank.
"""Create or update a memory bank (sync wrapper — prefer :meth:`acreate_bank` in async code).
Args:
bank_id: Unique identifier for the bank
@@ -420,6 +518,7 @@ class Hindsight:
enable_observations: Toggle automatic observation consolidation after retain().
observations_mission: Controls what gets synthesised into observations. Replaces built-in rules.
reflect_mission: Mission/context for Reflect operations.
background: Optional background context for the bank.
"""
return _run_async(
self._acreate_bank(
@@ -437,6 +536,7 @@ class Hindsight:
retain_chunk_size=retain_chunk_size,
enable_observations=enable_observations,
observations_mission=observations_mission,
background=background,
)
)
@@ -456,6 +556,7 @@ class Hindsight:
retain_chunk_size: int | None = None,
enable_observations: bool | None = None,
observations_mission: str | None = None,
background: str | None = None,
) -> BankProfileResponse:
import aiohttp
@@ -466,6 +567,8 @@ class Hindsight:
body["mission"] = mission
if reflect_mission is not None:
body["reflect_mission"] = reflect_mission
if background is not None:
body["background"] = background
# Individual disposition fields take priority over legacy disposition dict
if disposition_skepticism is not None:
body["disposition_skepticism"] = disposition_skepticism
@@ -528,8 +631,9 @@ class Hindsight:
enable_observations: bool | None = None,
observations_mission: str | None = None,
reflect_mission: str | None = None,
background: str | None = None,
) -> BankProfileResponse:
"""Create or update a memory bank (async).
"""Create or update a memory bank (async — preferred over :meth:`create_bank`).
Args:
bank_id: Unique identifier for the bank
@@ -546,6 +650,7 @@ class Hindsight:
enable_observations: Toggle automatic observation consolidation after retain().
observations_mission: Controls what gets synthesised into observations. Replaces built-in rules.
reflect_mission: Mission/context for Reflect operations.
background: Optional background context for the bank.
"""
return await self._acreate_bank(
bank_id,
@@ -562,6 +667,7 @@ class Hindsight:
retain_chunk_size=retain_chunk_size,
enable_observations=enable_observations,
observations_mission=observations_mission,
background=background,
)
async def aset_mission(self, bank_id: str, mission: str) -> dict[str, Any]:
@@ -581,11 +687,13 @@ class Hindsight:
retain_async: bool = False,
) -> RetainResponse:
"""
Store multiple memories in batch (async).
Store multiple memories in batch (async — preferred over :meth:`retain_batch`).
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
items: List of memory items, each a dict with 'content' (required) and optional keys:
'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags',
'observation_scopes' (str or list[list[str]]), 'strategy'.
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
@@ -594,6 +702,7 @@ class Hindsight:
RetainResponse with success status and item count
"""
from hindsight_client_api.models.entity_input import EntityInput
from hindsight_client_api.models.observation_scopes import ObservationScopes
from hindsight_client_api.models.timestamp import Timestamp
memory_items = []
@@ -603,6 +712,9 @@ class Hindsight:
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
raw_ts = item.get("timestamp")
timestamp_val = Timestamp(actual_instance=raw_ts) if raw_ts is not None else None
obs_scopes = None
if item.get("observation_scopes") is not None:
obs_scopes = ObservationScopes(actual_instance=item["observation_scopes"])
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
@@ -613,12 +725,14 @@ class Hindsight:
document_id=item.get("document_id") or document_id,
entities=entities,
tags=item.get("tags"),
observation_scopes=obs_scopes,
strategy=item.get("strategy"),
)
)
request_obj = retain_request.RetainRequest(
items=memory_items,
async_=retain_async,
var_async=retain_async,
document_tags=document_tags,
)
@@ -636,7 +750,7 @@ class Hindsight:
tags: list[str] | None = None,
) -> RetainResponse:
"""
Store a single memory (async).
Store a single memory (async — preferred over :meth:`retain`).
Args:
bank_id: The memory bank ID
@@ -683,9 +797,10 @@ class Hindsight:
max_source_facts_tokens: int = 4096,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
tag_groups: list[dict[str, Any]] | None = None,
) -> RecallResponse:
"""
Recall memories using semantic similarity (async).
Recall memories using semantic similarity (async — preferred over :meth:`recall`).
Args:
bank_id: The memory bank ID
@@ -704,6 +819,11 @@ class Hindsight:
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
tag_groups: Optional list of tag group filters for advanced boolean tag matching.
Each element is a dict representing a tag group node (TagGroupLeaf, TagGroupAnd,
TagGroupOr, or TagGroupNot). Example::
[{"tags": ["customer"], "match": "all"}, {"not": {"tags": ["internal"]}}]
Returns:
RecallResponse with results, optional entities, optional chunks, optional source_facts, and optional trace
@@ -725,6 +845,12 @@ class Hindsight:
else None,
)
tag_groups_objs = None
if tag_groups is not None:
from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner
tag_groups_objs = [RecallRequestTagGroupsInner.from_dict(tg) for tg in tag_groups]
request_obj = recall_request.RecallRequest(
query=query,
types=types,
@@ -735,6 +861,7 @@ class Hindsight:
include=include_opts,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups_objs,
)
return await self._memory_api.recall_memories(bank_id, request_obj, _request_timeout=self._timeout)
@@ -749,9 +876,14 @@ class Hindsight:
response_schema: dict[str, Any] | None = None,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
include_facts: bool = False,
tag_groups: list[dict[str, Any]] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
exclude_mental_model_ids: list[str] | None = None,
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories (async).
Generate a contextual answer based on bank identity and memories (async — preferred over :meth:`reflect`).
Args:
bank_id: The memory bank ID
@@ -765,11 +897,25 @@ class Hindsight:
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
include_facts: If True, the response will include a 'based_on' field listing
the memories, mental models, and directives used to construct the answer.
tag_groups: Optional list of tag group filters for advanced boolean tag matching.
fact_types: Optional list of fact types to include (world, experience, observation).
exclude_mental_models: If True, exclude all mental models from reflection (default: False).
exclude_mental_model_ids: Optional list of specific mental model IDs to exclude.
Returns:
ReflectResponse with answer text, optionally facts used, and optionally
structured_output if response_schema was provided
"""
include = ReflectIncludeOptions(facts={}) if include_facts else None
tag_groups_objs = None
if tag_groups is not None:
from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner
tag_groups_objs = [RecallRequestTagGroupsInner.from_dict(tg) for tg in tag_groups]
request_obj = reflect_request.ReflectRequest(
query=query,
budget=budget,
@@ -778,6 +924,11 @@ class Hindsight:
response_schema=response_schema,
tags=tags,
tags_match=tags_match,
include=include,
tag_groups=tag_groups_objs,
fact_types=fact_types,
exclude_mental_models=exclude_mental_models or None,
exclude_mental_model_ids=exclude_mental_model_ids,
)
return await self._memory_api.reflect(bank_id, request_obj, _request_timeout=self._timeout)
@@ -795,7 +946,7 @@ class Hindsight:
id: str | None = None,
):
"""
Create a mental model (runs reflect in background).
Create a mental model (sync wrapper — use ``await client.mental_models.create_mental_model(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -828,7 +979,7 @@ class Hindsight:
def list_mental_models(self, bank_id: str, tags: list[str] | None = None):
"""
List all mental models in a bank.
List all mental models in a bank (sync wrapper — use ``await client.mental_models.list_mental_models(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -841,7 +992,7 @@ class Hindsight:
def get_mental_model(self, bank_id: str, mental_model_id: str):
"""
Get a specific mental model.
Get a specific mental model (sync wrapper — use ``await client.mental_models.get_mental_model(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -854,7 +1005,7 @@ class Hindsight:
def refresh_mental_model(self, bank_id: str, mental_model_id: str):
"""
Refresh a mental model to update with current knowledge.
Refresh a mental model (sync wrapper — use ``await client.mental_models.refresh_mental_model(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -876,7 +1027,7 @@ class Hindsight:
trigger: dict[str, Any] | None = None,
):
"""
Update a mental model's metadata.
Update a mental model's metadata (sync wrapper — use ``await client.mental_models.update_mental_model(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -908,7 +1059,7 @@ class Hindsight:
def delete_mental_model(self, bank_id: str, mental_model_id: str):
"""
Delete a mental model.
Delete a mental model (sync wrapper — use ``await client.mental_models.delete_mental_model(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -918,7 +1069,7 @@ class Hindsight:
def get_mental_model_history(self, bank_id: str, mental_model_id: str):
"""
Get the content change history of a mental model.
Get the content change history of a mental model (sync wrapper — use ``await client.mental_models.get_mental_model_history(...)`` in async code).
Returns a list of history entries (most recent first), each with
``previous_content`` and ``changed_at`` fields.
@@ -941,7 +1092,7 @@ class Hindsight:
tags: list[str] | None = None,
):
"""
Create a directive (hard rule for reflect).
Create a directive (sync wrapper — use ``await client.directives.create_directive(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -968,7 +1119,7 @@ class Hindsight:
def list_directives(self, bank_id: str, tags: list[str] | None = None):
"""
List all directives in a bank.
List all directives in a bank (sync wrapper — use ``await client.directives.list_directives(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -981,7 +1132,7 @@ class Hindsight:
def get_directive(self, bank_id: str, directive_id: str):
"""
Get a specific directive.
Get a specific directive (sync wrapper — use ``await client.directives.get_directive(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -1003,7 +1154,7 @@ class Hindsight:
tags: list[str] | None = None,
):
"""
Update a directive.
Update a directive (sync wrapper — use ``await client.directives.update_directive(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -1031,7 +1182,7 @@ class Hindsight:
def delete_directive(self, bank_id: str, directive_id: str):
"""
Delete a directive.
Delete a directive (sync wrapper — use ``await client.directives.delete_directive(...)`` in async code).
Args:
bank_id: The memory bank ID
@@ -1041,7 +1192,7 @@ class Hindsight:
def get_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
Get the resolved configuration for a bank, including any bank-level overrides.
Get the resolved configuration for a bank (sync wrapper — use ``await client.banks.get_bank_config(...)`` in async code).
Can be disabled on the server by setting ``HINDSIGHT_API_ENABLE_BANK_CONFIG_API=false``.
@@ -1079,7 +1230,7 @@ class Hindsight:
disposition_empathy: int | None = None,
) -> dict[str, Any]:
"""
Update configuration overrides for a bank.
Update configuration overrides for a bank (sync wrapper — use ``await client.banks.update_bank_config(...)`` in async code).
Can be disabled on the server by setting ``HINDSIGHT_API_ENABLE_BANK_CONFIG_API=false``.
@@ -1131,7 +1282,7 @@ class Hindsight:
def reset_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
Reset all bank-level configuration overrides, reverting to server defaults.
Reset all bank-level config overrides (sync wrapper — use ``await client.banks.reset_bank_config(...)`` in async code).
Can be disabled on the server by setting ``HINDSIGHT_API_ENABLE_BANK_CONFIG_API=false``.
@@ -1155,7 +1306,7 @@ class Hindsight:
def delete_bank(self, bank_id: str):
"""
Delete a memory bank.
Delete a memory bank (sync wrapper — prefer :meth:`adelete_bank` in async code).
Args:
bank_id: The memory bank ID
@@ -1164,7 +1315,7 @@ class Hindsight:
async def adelete_bank(self, bank_id: str):
"""
Delete a memory bank (async).
Delete a memory bank (async — preferred over :meth:`delete_bank`).
Args:
bank_id: The memory bank ID
@@ -0,0 +1,42 @@
"""
Test that RetainRequest correctly serializes the async field.
Regression test for a bug where the client passed async_=True (invalid kwarg)
instead of var_async=True, causing async mode to be silently ignored.
"""
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.retain_request import RetainRequest
def _make_item():
return MemoryItem(content="test content")
def test_retain_request_async_true_serialized():
"""var_async=True must appear as 'async': True in the serialized dict."""
req = RetainRequest(items=[_make_item()], var_async=True)
d = req.to_dict()
assert d["async"] is True
def test_retain_request_async_false_serialized():
"""var_async=False (default) must appear as 'async': False."""
req = RetainRequest(items=[_make_item()], var_async=False)
d = req.to_dict()
assert d["async"] is False
def test_retain_request_default_is_sync():
"""Omitting var_async should default to synchronous (async=False)."""
req = RetainRequest(items=[_make_item()])
d = req.to_dict()
assert d["async"] is False
def test_retain_request_async_json_roundtrip():
"""async=True must survive a JSON serialization roundtrip."""
req = RetainRequest(items=[_make_item()], var_async=True)
json_str = req.to_json()
restored = RetainRequest.from_json(json_str)
assert restored.var_async is True
@@ -81,7 +81,7 @@ export ANTHROPIC_API_KEY="your-key"
export HINDSIGHT_LLM_PROVIDER=claude-code # No API key needed
```
The model is selected automatically by the Hindsight API. To override, set `HINDSIGHT_API_LLM_MODEL`.
The model is selected automatically by the Hindsight API. To override, set `HINDSIGHT_LLM_MODEL`.
### 3. Existing Local Server
@@ -89,61 +89,99 @@ If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and
## Configuration
All settings are in `~/.hindsight/claude-code.json`. Every setting can also be overridden via environment variables.
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
| Setting | Default | Env Var | Description |
These settings control how the plugin connects to the Hindsight API.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `""` | `HINDSIGHT_API_URL` | External Hindsight API URL. Empty = use local daemon. |
| `hindsightApiToken` | `null` | `HINDSIGHT_API_TOKEN` | Auth token for external 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` |
| `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 (daemon mode only)
---
| Setting | Default | Env Var | Description |
### 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` | auto-detect | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code` |
| `llmModel` | provider default | `HINDSIGHT_LLM_MODEL` | Model override |
| `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. |
Auto-detection checks these env vars in order: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
---
### Memory Bank
| Setting | Default | Env Var | Description |
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` | `"claude_code"` | `HINDSIGHT_BANK_ID` | Static bank ID (when `dynamicBankId` is false) |
| `bankMission` | generic assistant | `HINDSIGHT_BANK_MISSION` | Agent identity/purpose for the memory bank |
| `retainMission` | extraction prompt | — | Custom retain mission (what to extract from conversations) |
| `dynamicBankId` | `false` | `HINDSIGHT_DYNAMIC_BANK_ID` | Enable per-context memory banks |
| `dynamicBankGranularity` | `["agent", "project"]` | — | Fields for dynamic bank ID: `agent`, `project`, `session`, `channel`, `user` |
| `bankIdPrefix` | `""` | — | Prefix for all bank IDs (e.g. `"prod"`) |
| `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
| Setting | Default | Env Var | Description |
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` | `true` | `HINDSIGHT_AUTO_RECALL` | Enable automatic memory recall |
| `recallBudget` | `"mid"` | `HINDSIGHT_RECALL_BUDGET` | Recall effort: `low`, `mid`, `high` |
| `recallMaxTokens` | `1024` | `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens in recall response |
| `recallContextTurns` | `1` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | Prior turns for query composition |
| `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
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `true` | `HINDSIGHT_AUTO_RETAIN` | Enable automatic retention |
| `retainEveryNTurns` | `10` | — | Retain every Nth turn (sliding window) |
| `retainOverlapTurns` | `2` | — | Extra overlap turns for continuity |
| `retainRoles` | `["user", "assistant"]` | — | Which message roles to retain |
Auto-retain runs after Claude responds. It extracts the conversation transcript and sends it to Hindsight for long-term storage and fact extraction.
### Miscellaneous
| Setting | Default | Env Var | Description |
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `false` | `HINDSIGHT_DEBUG` | Enable debug logging to stderr |
| `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
+73 -47
View File
@@ -30,9 +30,6 @@ claude
That's it! The plugin will automatically start capturing and recalling memories.
> **Tip:** Once available in the official Claude Code plugin directory, installation will be a single command:
> `claude plugin install hindsight-memory`
## 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)
@@ -123,70 +120,99 @@ If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and
## Configuration
All settings are in `settings.json` at the plugin root. Every setting can also be overridden via environment variables.
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
| Setting | Default | Env Var | Description |
These settings control how the plugin connects to the Hindsight API.
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `""` | `HINDSIGHT_API_URL` | External Hindsight API URL. Empty = use local daemon. |
| `hindsightApiToken` | `null` | `HINDSIGHT_API_TOKEN` | Auth token for external 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` |
| `embedPackagePath` | `null` | `HINDSIGHT_EMBED_PACKAGE_PATH` | Local path to `hindsight-embed` for development |
| `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 (daemon mode only)
---
| Setting | Default | Env Var | Description |
### 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` | auto-detect | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code` |
| `llmModel` | provider default | `HINDSIGHT_LLM_MODEL` | Model override |
| `llmApiKeyEnv` | provider standard | — | Custom env var name for API key |
| `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. |
Auto-detection checks these env vars in order: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
---
### Memory Bank
| Setting | Default | Env Var | Description |
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` | `"claude_code"` | `HINDSIGHT_BANK_ID` | Static bank ID (when `dynamicBankId` is false) |
| `bankMission` | generic assistant | `HINDSIGHT_BANK_MISSION` | Agent identity/purpose for the memory bank |
| `retainMission` | extraction prompt | — | Custom retain mission (what to extract from conversations) |
| `dynamicBankId` | `false` | `HINDSIGHT_DYNAMIC_BANK_ID` | Enable per-context memory banks |
| `dynamicBankGranularity` | `["agent", "project"]` | — | Fields for dynamic bank ID: `agent`, `project`, `session`, `channel`, `user` |
| `bankIdPrefix` | `""` | — | Prefix for all bank IDs (e.g. `"prod"`) |
| `agentName` | `""` | `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank ID derivation |
| `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
| Setting | Default | Env Var | Description |
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` | `true` | `HINDSIGHT_AUTO_RECALL` | Enable automatic memory recall |
| `recallBudget` | `"mid"` | `HINDSIGHT_RECALL_BUDGET` | Recall effort: `low`, `mid`, `high` |
| `recallMaxTokens` | `1024` | `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens in recall response |
| `recallTypes` | `["world", "experience"]` | — | Memory types: `world`, `experience`, `observation` |
| `recallContextTurns` | `1` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | Prior turns for query composition (1 = latest only) |
| `recallMaxQueryChars` | `800` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | Max query length |
| `recallRoles` | `["user", "assistant"]` | — | Roles included in query context |
| `recallTopK` | `null` | — | Hard cap on memories per turn |
| `recallPromptPreamble` | built-in string | — | Text placed above recalled memories |
| `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
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `true` | `HINDSIGHT_AUTO_RETAIN` | Enable automatic retention |
| `retainRoles` | `["user", "assistant"]` | — | Which roles to retain |
| `retainEveryNTurns` | `10` | — | Retain every Nth turn. Values >1 enable chunked retention with a sliding window. |
| `retainOverlapTurns` | `2` | — | Extra overlap turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns` (default: 12 turns). |
| `retainContext` | `"claude-code"` | — | Context label for retained memories |
Auto-retain runs after Claude responds. It extracts the conversation transcript and sends it to Hindsight for long-term storage and fact extraction.
### Miscellaneous
| Setting | Default | Env Var | Description |
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `false` | `HINDSIGHT_DEBUG` | Enable debug logging to stderr |
| `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
@@ -214,7 +240,7 @@ And enable dynamic bank IDs:
### Plugin not activating
- Verify installation: check that `.claude-plugin/plugin.json` exists in the installed plugin directory
- Check Claude Code logs for `[Hindsight]` messages (enable `"debug": true` in settings.json)
- Check Claude Code logs for `[Hindsight]` messages (enable `"debug": true` in `~/.hindsight/claude-code.json`)
### Recall returning no memories
@@ -22,7 +22,6 @@ DEFAULTS = {
"conflicting). Only use memories that are directly useful to continue "
"this conversation; ignore the rest:"
),
"recallTopK": None,
# Retain
"autoRetain": True,
"retainMode": "full-session",
@@ -161,11 +161,6 @@ def main():
debug_log(config, "No memories found")
return
# Apply topK limit
top_k = config.get("recallTopK")
if top_k and isinstance(top_k, int):
results = results[:top_k]
debug_log(config, f"Injecting {len(results)} memories")
# Format context message — exact match of Openclaw's format
@@ -13,7 +13,6 @@
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
"recallTopK": null,
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
@@ -33,6 +33,7 @@ dependencies = [
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+5 -5
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -1018,6 +1017,7 @@ dependencies = [
{ name = "hindsight-client" },
{ name = "pillow" },
{ name = "pyjwt" },
{ name = "requests" },
]
[package.optional-dependencies]
@@ -1040,8 +1040,8 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" },
{ name = "requests", specifier = ">=2.33.0" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -3116,7 +3116,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -3124,9 +3124,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -30,6 +30,8 @@ classifiers = [
dependencies = [
"langchain-core>=0.3.0",
"hindsight-client>=0.4.0",
# Transitive dependency security fixes
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+8 -8
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
@@ -490,11 +489,12 @@ wheels = [
[[package]]
name = "hindsight-langgraph"
version = "0.1.0"
version = "0.1.1"
source = { editable = "." }
dependencies = [
{ name = "hindsight-client" },
{ name = "langchain-core" },
{ name = "requests" },
]
[package.optional-dependencies]
@@ -516,10 +516,10 @@ dev = [
requires-dist = [
{ name = "hindsight-client", specifier = ">=0.4.0" },
{ name = "langchain-core", specifier = ">=0.3.0" },
{ name = "langgraph", marker = "extra == 'all'", specifier = ">=0.5.0" },
{ name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=0.5.0" },
{ name = "langgraph", marker = "extra == 'all'", specifier = ">=0.3.0" },
{ name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=0.3.0" },
{ name = "requests", specifier = ">=2.33.0" },
]
provides-extras = ["langgraph", "all"]
[package.metadata.requires-dev]
dev = [
@@ -1349,7 +1349,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1357,9 +1357,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -37,6 +37,7 @@ dependencies = [
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"filelock>=3.20.3", # TOCTOU race condition
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass
"requests>=2.33.0", # Insecure temp file reuse in extract_zipped_paths()
]
[project.optional-dependencies]
+7 -7
View File
@@ -1,5 +1,4 @@
version = 1
revision = 1
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -691,12 +690,13 @@ wheels = [
[[package]]
name = "hindsight-litellm"
version = "0.4.19"
version = "0.5.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "filelock" },
{ name = "litellm" },
{ name = "requests" },
{ name = "urllib3" },
]
@@ -716,13 +716,13 @@ dev = [
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "filelock", specifier = ">=3.20.3" },
{ name = "litellm", specifier = ">=1.40.0" },
{ name = "litellm", specifier = ">=1.40.0,<=1.82.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" },
{ name = "requests", specifier = ">=2.33.0" },
{ name = "urllib3", specifier = ">=2.6.3" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -1723,7 +1723,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1731,9 +1731,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
]
[[package]]
@@ -7,3 +7,27 @@ import PageHero from '@site/src/components/PageHero';
<PageHero title="Claude Code Changelog" subtitle="hindsight-memory — Hindsight memory plugin for Claude Code." />
[← Claude Code integration](../../sdks/integrations/claude-code.md)
## [0.3.0](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.3.0)
**Features**
- Claude Code integration now retains tool calls as structured JSON for more accurate memory and retrieval. ([`8cb8b912`](https://github.com/vectorize-io/hindsight/commit/8cb8b912))
## [0.2.0](https://github.com/vectorize-io/hindsight/tree/integrations/claude-code/v0.2.0)
**Features**
- Added a Claude Code integration plugin for capturing and using Hindsight memory in Claude Code. ([`f4390bdc`](https://github.com/vectorize-io/hindsight/commit/f4390bdc))
- Claude Code integration can retain full sessions with document upsert and configurable tagging. ([`2d31b67d`](https://github.com/vectorize-io/hindsight/commit/2d31b67d))
**Improvements**
- Improved Claude Code plugin installation and configuration experience. ([`35b2cbb6`](https://github.com/vectorize-io/hindsight/commit/35b2cbb6))
- Integrations no longer rely on hardcoded default models, allowing model selection to be fully configured. ([`58e68f3e`](https://github.com/vectorize-io/hindsight/commit/58e68f3e))
- Claude Code now starts the Hindsight background daemon automatically at session start for smoother operation. ([`26944e25`](https://github.com/vectorize-io/hindsight/commit/26944e25))
**Bug Fixes**
- Added a supported setup command to register hooks reliably, fixing hook registration issues. ([`22ca6a8d`](https://github.com/vectorize-io/hindsight/commit/22ca6a8d))
- Fixed Claude Code integration compatibility on Windows. ([`a94a90ea`](https://github.com/vectorize-io/hindsight/commit/a94a90ea))
Generated
+5 -3
View File
@@ -1568,6 +1568,7 @@ dependencies = [
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "tiktoken" },
@@ -1683,6 +1684,7 @@ requires-dist = [
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.0.0" },
{ name = "python-dateutil", specifier = ">=2.8.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "safetensors", marker = "extra == 'local-ml'", specifier = ">=0.6.2" },
{ name = "sentence-transformers", marker = "extra == 'local-ml'", specifier = ">=3.3.0" },
@@ -4180,11 +4182,11 @@ wheels = [
[[package]]
name = "python-multipart"
version = "0.0.20"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 },
]
[[package]]