Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d56ec2ea | ||
|
|
599a29b01b | ||
|
|
c9ff37dcbf | ||
|
|
91397190c0 | ||
|
|
fd88c0efa5 | ||
|
|
ea4df8dbb5 |
+32
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+5
-5
@@ -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]
|
||||
|
||||
Generated
+8
-8
@@ -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]
|
||||
|
||||
Generated
+7
-7
@@ -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))
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user