feat: ai sdk integration (#299)

* feat: ai sdk integration

* more fixes

* fix(security): mental model refresh tag-based security

- Mental model refresh now passes tags with all_strict matching
- Consolidation only triggers refresh for mental models with matching tags
- Consolidation filters related observations by tags (all_strict)
- Added tests to verify tag-based security boundaries
- Updated OpenAPI spec to include tags and text_preview in list_documents
- Added tags column to documents UI table

* chore: regenerate OpenAPI spec after rebase

* fix: improve consolidation prompt for contradiction handling and mental model refresh security

- Enhanced consolidation prompt to be more explicit about capturing temporal changes in contradictions
- Fixed mental model refresh security: tagged memories now only trigger refresh of mental models with matching tags
- Added stricter tag filtering to prevent cross-scope mental model refreshes

Fixes test_consolidation_merges_contradictions by improving LLM instructions to use temporal markers like "used to X, now Y" when merging contradictory facts.

Note: test_refresh_with_tags_only_accesses_same_tagged_models still needs investigation - REFLECT operation may need additional tag filtering.

* fix: mental model refresh security - proper tag filtering in search

Fixed tool_search_mental_models to properly handle all_strict tag matching mode by using the centralized build_tags_where_clause function. Previously, the function only handled "all" vs "any" modes and always included untagged mental models when using non-"all" modes.

This ensures that when a tagged mental model is refreshed with all_strict matching, it cannot access untagged mental models, preventing cross-scope information leakage.

Fixes test_refresh_with_tags_only_accesses_same_tagged_models.

Note: test_sensory_dimension_preservation is failing but this is a pre-existing issue on main branch - the LLM model (gpt-oss-20b) is not extracting facts from sensory text. Not related to security changes.

* chore: apply formatting from pre-commit hook

* fix: allow untagged mental models to be refreshed by any consolidation

Untagged mental models are considered "global" and should be refreshed
by any consolidation, regardless of whether tagged or untagged memories
were consolidated. This maintains security boundaries while allowing
global mental models to stay fresh.

When tagged memories are consolidated:
- Refresh mental models with matching tags (security boundary)
- Also refresh untagged mental models (they're global)
- DO NOT refresh mental models with different tags

When untagged memories are consolidated:
- Only refresh untagged mental models
- DO NOT refresh tagged mental models (security boundary)

Fixes test_consolidation_only_refreshes_matching_tagged_models.
This commit is contained in:
Nicolò Boschi 2026-02-04 20:25:59 +01:00 committed by GitHub
parent dd621a69d0
commit 7e339e1677
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3889 additions and 214 deletions

View file

@ -188,6 +188,55 @@ jobs:
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@ -415,7 +464,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@ -444,6 +493,12 @@ jobs:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
@ -487,6 +542,8 @@ jobs:
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries

View file

@ -74,6 +74,29 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
build-ai-sdk-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/ai-sdk
run: npm test
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
build-control-plane:
runs-on: ubuntu-latest

View file

@ -866,6 +866,7 @@ class ListDocumentsResponse(BaseModel):
"updated_at": "2024-01-15T10:30:00Z",
"text_length": 5420,
"memory_unit_count": 15,
"tags": ["user_a", "session_123"],
}
],
"total": 50,
@ -1160,7 +1161,8 @@ class CreateMentalModelRequest(BaseModel):
class CreateMentalModelResponse(BaseModel):
"""Response model for mental model creation."""
operation_id: str = Field(description="Operation ID to track progress")
mental_model_id: str = Field(description="ID of the created mental model")
operation_id: str = Field(description="Operation ID to track refresh progress")
class UpdateMentalModelRequest(BaseModel):
@ -2423,7 +2425,7 @@ def _register_routes(app: FastAPI):
mental_model_id=mental_model["id"],
request_context=request_context,
)
return CreateMentalModelResponse(operation_id=result["operation_id"])
return CreateMentalModelResponse(mental_model_id=mental_model["id"], operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):

View file

@ -143,6 +143,9 @@ async def run_consolidation_job(
"skipped": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
consolidated_tags: set[str] = set()
batch_num = 0
last_progress_timings = {} # Track timings at last progress log
while True:
@ -176,6 +179,11 @@ async def run_consolidation_job(
for memory in memories:
mem_start = time.time()
# Track tags from this memory for mental model refresh filtering
memory_tags = memory.get("tags") or []
if memory_tags:
consolidated_tags.update(memory_tags)
# Process the memory (uses its own connection internally)
async with pool.acquire() as conn:
result = await _process_memory(
@ -284,10 +292,12 @@ async def run_consolidation_job(
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
consolidated_tags=list(consolidated_tags) if consolidated_tags else None,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
@ -301,15 +311,20 @@ async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
consolidated_tags: list[str] | None = None,
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
SECURITY: Only triggers refresh for mental models whose tags overlap with the
consolidated memory tags, preventing unnecessary refreshes across security boundaries.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
consolidated_tags: Tags from memories that were consolidated (None = refresh all)
perf: Performance logging
Returns:
@ -318,22 +333,52 @@ async def _trigger_mental_model_refreshes(
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
# SECURITY: Control which mental models get refreshed based on tags
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if consolidated_tags:
# Tagged memories were consolidated - refresh:
# 1. Mental models with overlapping tags (security boundary)
# 2. Untagged mental models (they're "global" and available to all contexts)
# DO NOT refresh mental models with different tags
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (
(tags IS NOT NULL AND tags != '{{}}' AND tags && $2::varchar[])
OR (tags IS NULL OR tags = '{{}}')
)
""",
bank_id,
consolidated_tags,
)
else:
# Untagged memories were consolidated - only refresh untagged mental models
# SECURITY: Tagged mental models are NOT refreshed when untagged memories are consolidated
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (tags IS NULL OR tags = '{{}}')
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
if consolidated_tags:
perf.log(
f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true "
f"(filtered by tags: {consolidated_tags})"
)
else:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
@ -385,7 +430,8 @@ async def _process_memory(
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system (NO tag filtering)
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
@ -393,6 +439,7 @@ async def _process_memory(
bank_id=bank_id,
query=fact_text,
request_context=request_context,
tags=fact_tags, # Pass source memory's tags for security
)
if perf:
perf.record_timing("recall", time.time() - t0)
@ -666,17 +713,20 @@ async def _find_related_observations(
bank_id: str,
query: str,
request_context: "RequestContext",
tags: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using optimized recall.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related observations regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
SECURITY: Filters by tags using all_strict matching to prevent cross-tenant/cross-user
information leakage. Observations are only consolidated within the same tag scope.
Uses max_tokens to naturally limit observations (no artificial count limit).
Includes source memories with dates for LLM context.
Args:
tags: Optional tags to filter observations (uses all_strict matching for security)
Returns:
List of related observations with their tags, source memories, and dates
"""
@ -685,14 +735,19 @@ async def _find_related_observations(
from ...config import get_config
config = get_config()
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL observations
)
# If no observations returned, return empty list

View file

@ -32,13 +32,16 @@ BAD examples:
## MERGE RULES (when comparing to existing observations):
1. REDUNDANT: Same information worded differently update existing
2. CONTRADICTION: Opposite information about same topic update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state update with history
2. CONTRADICTION: Opposite information about same topic update with temporal markers showing change
Example: "Alex used to love pizza but now hates it" OR "Alex's pizza preference changed from love to hate"
3. UPDATE: New state replacing old state update showing the transition with "used to", "now", "changed from X to Y"
## CRITICAL RULES:
- NEVER merge facts about DIFFERENT people
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
- When merging contradictions, capture the CHANGE (before after)
- When merging contradictions, the "text" field MUST capture BOTH states with temporal markers:
* Use "used to X, now Y" OR "changed from X to Y" OR "X but now Y"
* DO NOT just state the new fact - you MUST show the change
- Keep observations focused on ONE specific topic per person
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""

View file

@ -625,11 +625,19 @@ class MemoryEngine(MemoryEngineInterface):
source_query = mental_model["source_query"]
# SECURITY: If the mental model has tags, pass them to reflect with "all_strict" matching
# to ensure it can only access other mental models/memories with the SAME tags.
# This prevents cross-tenant/cross-user information leakage by excluding untagged content.
tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any"
# Run reflect to generate new content, excluding the mental model being refreshed
reflect_result = await self.reflect_async(
bank_id=bank_id,
query=source_query,
request_context=internal_context,
tags=tags,
tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id],
)
@ -3304,7 +3312,8 @@ class MemoryEngine(MemoryEngineInterface):
created_at,
updated_at,
LENGTH(original_text) as text_length,
retain_params
retain_params,
tags
FROM {fq_table("documents")}
{where_clause}
ORDER BY created_at DESC
@ -3360,6 +3369,7 @@ class MemoryEngine(MemoryEngineInterface):
"text_length": row["text_length"] or 0,
"memory_unit_count": unit_count,
"retain_params": row["retain_params"] if row["retain_params"] else None,
"tags": row["tags"] if row["tags"] else [],
}
)
@ -4719,11 +4729,19 @@ class MemoryEngine(MemoryEngineInterface):
if not mental_model:
return None
# SECURITY: If the mental model has tags, pass them to reflect with "all_strict" matching
# to ensure it can only access other mental models/memories with the SAME tags.
# This prevents cross-tenant/cross-user information leakage by excluding untagged content.
tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any"
# Run reflect with the source query, excluding the mental model being refreshed
reflect_result = await self.reflect_async(
bank_id=bank_id,
query=mental_model["source_query"],
request_context=request_context,
tags=tags,
tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id],
)

View file

@ -54,19 +54,18 @@ async def tool_search_mental_models(
Dict with matching mental models including content and freshness info
"""
from ..memory_engine import fq_table
from ..search.tags import build_tags_where_clause
# Build filters dynamically
filters = ""
params: list[Any] = [bank_id, str(query_embedding), max_results]
next_param = 4
# Use the centralized tag filtering logic
if tags:
if tags_match == "all":
filters += f" AND tags @> ${next_param}::varchar[]"
else:
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
params.append(tags)
next_param += 1
tag_clause, tag_params, next_param = build_tags_where_clause(tags, param_offset=next_param, match=tags_match)
filters += f" {tag_clause}"
params.extend(tag_params)
if exclude_ids:
filters += f" AND id != ALL(${next_param}::text[])"

View file

@ -158,6 +158,13 @@ async def retain_batch(
# Handle document tracking even with no facts
if document_id:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if contents_dicts:
first_item = contents_dicts[0]
@ -172,7 +179,7 @@ async def retain_batch(
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
else:
# Check for per-item document_ids
@ -186,6 +193,13 @@ async def retain_batch(
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
@ -200,7 +214,7 @@ async def retain_batch(
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, document_tags
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, merged_tags
)
total_time = time.time() - start_time
@ -252,6 +266,13 @@ async def retain_batch(
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
@ -266,7 +287,7 @@ async def retain_batch(
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
@ -294,6 +315,13 @@ async def retain_batch(
# Combine content for this document
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Extract retain params from first content item
retain_params = {}
if doc_contents:
@ -316,7 +344,7 @@ async def retain_batch(
combined_content,
is_first_batch,
retain_params,
document_tags,
merged_tags,
)
document_ids_added.append(actual_doc_id)

View file

@ -451,3 +451,198 @@ class TestDirectivesPromptInjection:
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
class TestMentalModelRefreshTagSecurity:
"""Test that mental model refresh respects tag-based security boundaries."""
async def test_refresh_with_tags_only_accesses_same_tagged_models(
self, memory: MemoryEngine, request_context
):
"""Test that refreshing a mental model with tags can only access other models with the same tags.
This is a security test to ensure that mental models with tags (e.g., user:alice)
cannot access mental models from other scopes (e.g., user:bob or no tags) during refresh.
"""
bank_id = f"test-refresh-tags-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some facts with different tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice works on the frontend React project. Alice's favorite color is blue.", "tags": ["user:alice"]},
{"content": "Alice prefers working in the morning. Alice drinks coffee every day.", "tags": ["user:alice"]},
{"content": "Bob works on the backend API services. Bob's favorite language is Python.", "tags": ["user:bob"]},
{"content": "Bob prefers working at night. Bob drinks tea every day.", "tags": ["user:bob"]},
{"content": "The company has 100 employees and is growing fast.", "tags": []}, # No tags
],
request_context=request_context,
)
# Wait for background processing
await memory.wait_for_background_tasks()
# Create mental model for user:alice with sensitive data
mm_alice = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Work Profile",
source_query="What does Alice work on?",
content="Alice is a frontend engineer specializing in React",
tags=["user:alice"],
request_context=request_context,
)
# Create mental model for user:bob with sensitive data
mm_bob = await memory.create_mental_model(
bank_id=bank_id,
name="Bob's Work Profile",
source_query="What does Bob work on?",
content="Bob is a backend engineer specializing in Python",
tags=["user:bob"],
request_context=request_context,
)
# Create mental model with no tags (should not be accessible from tagged models)
mm_untagged = await memory.create_mental_model(
bank_id=bank_id,
name="Company Info",
source_query="What is the company info?",
content="The company has 100 employees",
request_context=request_context,
)
# Create a mental model for user:alice that will be refreshed
mm_alice_refresh = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Summary",
source_query="What are all the facts about work and preferences?", # Broad query that should match all facts
content="Initial content",
tags=["user:alice"],
request_context=request_context,
)
# Refresh Alice's mental model
refreshed = await memory.refresh_mental_model(
bank_id=bank_id,
mental_model_id=mm_alice_refresh["id"],
request_context=request_context,
)
# SECURITY CHECK: The refreshed content should ONLY include information from
# memories/models tagged with user:alice, NOT from user:bob or untagged
refreshed_content = refreshed["content"].lower()
# Should include Alice's content (either from facts or mental models)
assert "alice" in refreshed_content, \
"Refreshed model should access memories/models with matching tags (user:alice)"
# MUST NOT include Bob's content (security violation)
assert "bob" not in refreshed_content and "python" not in refreshed_content and "tea" not in refreshed_content, \
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed_content}"
# MUST NOT include untagged content (security violation)
assert "100 employees" not in refreshed_content and "growing fast" not in refreshed_content, \
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed_content}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
async def test_consolidation_only_refreshes_matching_tagged_models(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation only triggers refresh for mental models with matching tags.
This is a security test to ensure that when tagged memories are consolidated,
only mental models with overlapping tags get refreshed, not all mental models.
"""
bank_id = f"test-consolidation-refresh-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
# Create mental models with different tags, all with refresh_after_consolidation=true
mm_alice = await memory.create_mental_model(
bank_id=bank_id,
name="Alice's Model",
source_query="What about Alice?",
content="Initial Alice content",
tags=["user:alice"],
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
mm_bob = await memory.create_mental_model(
bank_id=bank_id,
name="Bob's Model",
source_query="What about Bob?",
content="Initial Bob content",
tags=["user:bob"],
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
mm_untagged = await memory.create_mental_model(
bank_id=bank_id,
name="Untagged Model",
source_query="What about general stuff?",
content="Initial untagged content",
trigger={"refresh_after_consolidation": True},
request_context=request_context,
)
# Record initial last_refreshed_at timestamps
alice_initial = mm_alice["last_refreshed_at"]
bob_initial = mm_bob["last_refreshed_at"]
untagged_initial = mm_untagged["last_refreshed_at"]
# Add memories with user:alice tags
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice likes React", "tags": ["user:alice"]},
{"content": "Alice drinks coffee", "tags": ["user:alice"]},
],
request_context=request_context,
)
# Trigger consolidation manually (this should only refresh Alice's mental model)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
# Wait for background refresh tasks to complete
await memory.wait_for_background_tasks()
# Check that mental models were refreshed appropriately
mm_alice_after = await memory.get_mental_model(
bank_id, mm_alice["id"], request_context=request_context
)
mm_bob_after = await memory.get_mental_model(
bank_id, mm_bob["id"], request_context=request_context
)
mm_untagged_after = await memory.get_mental_model(
bank_id, mm_untagged["id"], request_context=request_context
)
# SECURITY CHECK: Only Alice's mental model and untagged model should be refreshed
# Alice's model should be refreshed (tags match)
assert mm_alice_after["last_refreshed_at"] != alice_initial or mm_alice_after["content"] != mm_alice["content"], \
"Alice's mental model should be refreshed when user:alice memories are consolidated"
# Bob's model should NOT be refreshed (tags don't match)
assert mm_bob_after["last_refreshed_at"] == bob_initial, \
"SECURITY VIOLATION: Bob's mental model was refreshed even though user:bob memories were not consolidated"
# Untagged model should be refreshed (untagged models are always refreshed)
assert mm_untagged_after["last_refreshed_at"] != untagged_initial or mm_untagged_after["content"] != mm_untagged["content"], \
"Untagged mental model should be refreshed after any consolidation"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)

View file

@ -2193,3 +2193,65 @@ If the text contains both Italian and English content, extract ONLY the Italian
# Clear cache again to restore original config
clear_config_cache()
@pytest.mark.asyncio
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
"""
Test that per-item tags are correctly stored on documents.
This test verifies the fix for a bug where per-item tags in content dictionaries
were not being merged and passed to document tracking, causing tags to be lost
even though they were correctly sent through the API.
Without the fix, this test would fail because:
- Tags are correctly passed in the content dict
- Tags are correctly stored on memory_units (facts)
- BUT tags were NOT stored on the document record itself
"""
bank_id = f"test_doc_tags_{datetime.now(timezone.utc).timestamp()}"
document_id = "app-state-testuser"
try:
# Retain content with per-item tags (simulating the TasteAI use case)
contents = [
{
"content": '{"username":"testuser","meals":[],"preferences":{"nickname":"testuser"}}',
"document_id": document_id,
"tags": ["user:testuser", "app-type:taste-ai"],
}
]
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
assert len(result) > 0, "Should have retained content"
print(f"\n=== Retained content with tags ===")
# Retrieve the document
doc = await memory.get_document(
document_id=document_id,
bank_id=bank_id,
request_context=request_context,
)
assert doc is not None, "Document should exist"
assert "tags" in doc, "Document should have tags field"
# This is the critical assertion - tags should be stored on the document
doc_tags = doc["tags"] or []
print(f"Document tags: {doc_tags}")
assert "user:testuser" in doc_tags, \
f"Document should have 'user:testuser' tag, but got: {doc_tags}"
assert "app-type:taste-ai" in doc_tags, \
f"Document should have 'app-type:taste-ai' tag, but got: {doc_tags}"
print("✓ Per-item tags correctly stored on document")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
print(f"\n=== Cleaned up bank: {bank_id} ===")

View file

@ -26,8 +26,9 @@ class CreateMentalModelResponse(BaseModel):
"""
Response model for mental model creation.
""" # noqa: E501
operation_id: StrictStr = Field(description="Operation ID to track progress")
__properties: ClassVar[List[str]] = ["operation_id"]
mental_model_id: StrictStr = Field(description="ID of the created mental model")
operation_id: StrictStr = Field(description="Operation ID to track refresh progress")
__properties: ClassVar[List[str]] = ["mental_model_id", "operation_id"]
model_config = ConfigDict(
populate_by_name=True,
@ -80,6 +81,7 @@ class CreateMentalModelResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"mental_model_id": obj.get("mental_model_id"),
"operation_id": obj.get("operation_id")
})
return _obj

View file

@ -435,10 +435,16 @@ export type CreateMentalModelRequest = {
* Response model for mental model creation.
*/
export type CreateMentalModelResponse = {
/**
* Mental Model Id
*
* ID of the created mental model
*/
mental_model_id: string;
/**
* Operation Id
*
* Operation ID to track progress
* Operation ID to track refresh progress
*/
operation_id: string;
};

View file

@ -23,7 +23,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { X, Trash2 } from "lucide-react";
import { X, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
const ITEMS_PER_PAGE = 50;
export function DocumentsView() {
const { currentBank } = useBank();
@ -32,6 +34,11 @@ export function DocumentsView() {
const [searchQuery, setSearchQuery] = useState("");
const [total, setTotal] = useState(0);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
// Document view panel state
const [selectedDocument, setSelectedDocument] = useState<any>(null);
const [loadingDocument, setLoadingDocument] = useState(false);
@ -46,15 +53,17 @@ export function DocumentsView() {
null
);
const loadDocuments = async () => {
const loadDocuments = async (page: number = 1) => {
if (!currentBank) return;
setLoading(true);
try {
const pageOffset = (page - 1) * ITEMS_PER_PAGE;
const data: any = await client.listDocuments({
bank_id: currentBank,
q: searchQuery,
limit: 100,
limit: ITEMS_PER_PAGE,
offset: pageOffset,
});
setDocuments(data.items || []);
setTotal(data.total || 0);
@ -66,6 +75,12 @@ export function DocumentsView() {
}
};
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
loadDocuments(newPage);
};
const viewDocumentText = async (documentId: string) => {
if (!currentBank) return;
@ -103,8 +118,8 @@ export function DocumentsView() {
setSelectedDocument(null);
}
// Reload documents list
loadDocuments();
// Reload documents list at current page
loadDocuments(currentPage);
} catch (error) {
console.error("Error deleting document:", error);
setDeleteResult({
@ -120,13 +135,26 @@ export function DocumentsView() {
setDocumentToDelete({ id: documentId, memoryCount });
};
// Auto-load documents when component mounts
// Auto-load documents when component mounts or bank changes
useEffect(() => {
if (currentBank) {
loadDocuments();
setCurrentPage(1);
loadDocuments(1);
}
}, [currentBank]);
// Reload when search query changes (with debounce)
useEffect(() => {
if (!currentBank) return;
const timeoutId = setTimeout(() => {
setCurrentPage(1);
loadDocuments(1);
}, 300); // 300ms debounce
return () => clearTimeout(timeoutId);
}, [searchQuery]);
return (
<div>
{/* Documents List Section */}
@ -138,19 +166,10 @@ export function DocumentsView() {
</div>
</div>
) : documents.length > 0 ? (
<div className="mb-4 text-sm text-muted-foreground">{total} total documents</div>
) : (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">📄</div>
<div className="text-sm text-muted-foreground">No documents found</div>
</div>
</div>
)}
{/* Documents List and Detail Panel */}
{documents.length > 0 && (
<>
<div className="mb-4 text-sm text-muted-foreground">
{total} {total === 1 ? "document" : "documents"}
</div>
{/* Documents Table */}
<div className="w-full">
<div className="px-5 mb-4">
@ -169,6 +188,7 @@ export function DocumentsView() {
<TableRow>
<TableHead>Document ID</TableHead>
<TableHead>Created</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Context</TableHead>
<TableHead>Text Length</TableHead>
<TableHead>Memory Units</TableHead>
@ -188,6 +208,27 @@ export function DocumentsView() {
<TableCell className="text-card-foreground">
{doc.created_at ? new Date(doc.created_at).toLocaleString() : "N/A"}
</TableCell>
<TableCell className="text-card-foreground">
{doc.tags && doc.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{doc.tags.slice(0, 3).map((tag: string, i: number) => (
<span
key={i}
className="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium"
>
{tag}
</span>
))}
{doc.tags.length > 3 && (
<span className="text-xs px-2 py-0.5 text-muted-foreground">
+{doc.tags.length - 3}
</span>
)}
</div>
) : (
"-"
)}
</TableCell>
<TableCell className="text-card-foreground">
{doc.retain_params?.context || "-"}
</TableCell>
@ -201,7 +242,7 @@ export function DocumentsView() {
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center">
<TableCell colSpan={6} className="text-center">
Click "Load Documents" to view data
</TableCell>
</TableRow>
@ -209,176 +250,230 @@ export function DocumentsView() {
</TableBody>
</Table>
</div>
</div>
{/* Document Detail Panel - Fixed on Right */}
{selectedDocument && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="p-5">
{/* Header with close button */}
<div className="flex justify-between items-center mb-6 pb-4 border-b border-border">
<div>
<h3 className="text-xl font-bold text-foreground">Document Details</h3>
<p className="text-sm text-muted-foreground mt-1">
Original document text and metadata
</p>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 pt-3 border-t px-5">
<div className="text-xs text-muted-foreground">
{offset + 1}-{Math.min(offset + ITEMS_PER_PAGE, total)} of {total}
</div>
<div className="flex items-center gap-1">
<Button
variant="secondary"
variant="outline"
size="sm"
onClick={() => setSelectedDocument(null)}
className="h-9 px-3 gap-2"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
Close
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-7 w-7 p-0"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-xs px-2">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="h-7 w-7 p-0"
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages || loading}
className="h-7 w-7 p-0"
>
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2">📄</div>
<div className="text-sm text-muted-foreground">No documents found</div>
</div>
</div>
)}
{/* Document Detail Panel - Fixed on Right */}
{documents.length > 0 && selectedDocument && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="p-5">
{/* Header with close button */}
<div className="flex justify-between items-center mb-6 pb-4 border-b border-border">
<div>
<h3 className="text-xl font-bold text-foreground">Document Details</h3>
<p className="text-sm text-muted-foreground mt-1">
Original document text and metadata
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedDocument(null)}
className="h-9 px-3 gap-2"
>
<X className="h-4 w-4" />
Close
</Button>
</div>
{loadingDocument ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-muted-foreground">Loading document...</div>
</div>
</div>
) : (
<div className="space-y-5">
{/* Document ID */}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Document ID
</div>
<div className="text-sm font-mono break-all text-card-foreground">
{selectedDocument.id}
</div>
</div>
{/* Created & Memory Units */}
{selectedDocument.created_at && (
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Created
</div>
<div className="text-sm font-medium text-card-foreground">
{new Date(selectedDocument.created_at).toLocaleString()}
</div>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Memory Units
</div>
<div className="text-sm font-medium text-card-foreground">
{selectedDocument.memory_unit_count}
</div>
</div>
</div>
)}
{/* Text Length */}
{selectedDocument.original_text && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Text Length
</div>
<div className="text-sm font-medium text-card-foreground">
{selectedDocument.original_text.length.toLocaleString()} characters
</div>
</div>
)}
{/* Retain Parameters */}
{selectedDocument.retain_params && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Retain Parameters
</div>
<div className="text-sm space-y-2 text-card-foreground">
{selectedDocument.retain_params.context && (
<div>
<span className="font-semibold">Context:</span>{" "}
{selectedDocument.retain_params.context}
</div>
)}
{selectedDocument.retain_params.event_date && (
<div>
<span className="font-semibold">Event Date:</span>{" "}
{new Date(selectedDocument.retain_params.event_date).toLocaleString()}
</div>
)}
{selectedDocument.retain_params.metadata && (
<div className="mt-2">
<span className="font-semibold">Metadata:</span>
<pre className="mt-1 text-xs bg-background p-2 rounded text-card-foreground">
{JSON.stringify(selectedDocument.retain_params.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Tags */}
{selectedDocument.tags && selectedDocument.tags.length > 0 && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Tags
</div>
<div className="flex flex-wrap gap-2">
{selectedDocument.tags.map((tag: string, i: number) => (
<span
key={i}
className="text-sm px-3 py-1.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Delete Button */}
<div className="pt-2 border-t border-border">
<Button
variant="destructive"
size="sm"
onClick={() =>
requestDeleteDocument(selectedDocument.id, selectedDocument.memory_unit_count)
}
className="w-full gap-2"
disabled={deletingDocumentId === selectedDocument.id}
>
{deletingDocumentId === selectedDocument.id ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
Delete Document
</Button>
</div>
{loadingDocument ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-muted-foreground">Loading document...</div>
{/* Original Text */}
{selectedDocument.original_text && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Original Text
</div>
</div>
) : (
<div className="space-y-5">
{/* Document ID */}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Document ID
</div>
<div className="text-sm font-mono break-all text-card-foreground">
{selectedDocument.id}
</div>
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
{selectedDocument.original_text}
</pre>
</div>
{/* Created & Memory Units */}
{selectedDocument.created_at && (
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Created
</div>
<div className="text-sm font-medium text-card-foreground">
{new Date(selectedDocument.created_at).toLocaleString()}
</div>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Memory Units
</div>
<div className="text-sm font-medium text-card-foreground">
{selectedDocument.memory_unit_count}
</div>
</div>
</div>
)}
{/* Text Length */}
{selectedDocument.original_text && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Text Length
</div>
<div className="text-sm font-medium text-card-foreground">
{selectedDocument.original_text.length.toLocaleString()} characters
</div>
</div>
)}
{/* Retain Parameters */}
{selectedDocument.retain_params && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Retain Parameters
</div>
<div className="text-sm space-y-2 text-card-foreground">
{selectedDocument.retain_params.context && (
<div>
<span className="font-semibold">Context:</span>{" "}
{selectedDocument.retain_params.context}
</div>
)}
{selectedDocument.retain_params.event_date && (
<div>
<span className="font-semibold">Event Date:</span>{" "}
{new Date(selectedDocument.retain_params.event_date).toLocaleString()}
</div>
)}
{selectedDocument.retain_params.metadata && (
<div className="mt-2">
<span className="font-semibold">Metadata:</span>
<pre className="mt-1 text-xs bg-background p-2 rounded text-card-foreground">
{JSON.stringify(selectedDocument.retain_params.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Tags */}
{selectedDocument.tags && selectedDocument.tags.length > 0 && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Tags
</div>
<div className="flex flex-wrap gap-2">
{selectedDocument.tags.map((tag: string, i: number) => (
<span
key={i}
className="text-sm px-3 py-1.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 font-medium"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Delete Button */}
<div className="pt-2 border-t border-border">
<Button
variant="destructive"
size="sm"
onClick={() =>
requestDeleteDocument(
selectedDocument.id,
selectedDocument.memory_unit_count
)
}
className="w-full gap-2"
disabled={deletingDocumentId === selectedDocument.id}
>
{deletingDocumentId === selectedDocument.id ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
Delete Document
</Button>
</div>
{/* Original Text */}
{selectedDocument.original_text && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Original Text
</div>
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
{selectedDocument.original_text}
</pre>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
)}
{/* Delete Confirmation Dialog */}

View file

@ -3638,14 +3638,20 @@
},
"CreateMentalModelResponse": {
"properties": {
"mental_model_id": {
"type": "string",
"title": "Mental Model Id",
"description": "ID of the created mental model"
},
"operation_id": {
"type": "string",
"title": "Operation Id",
"description": "Operation ID to track progress"
"description": "Operation ID to track refresh progress"
}
},
"type": "object",
"required": [
"mental_model_id",
"operation_id"
],
"title": "CreateMentalModelResponse",
@ -4401,6 +4407,10 @@
"created_at": "2024-01-15T10:30:00Z",
"id": "session_1",
"memory_unit_count": 15,
"tags": [
"user_a",
"session_123"
],
"text_length": 5420,
"updated_at": "2024-01-15T10:30:00Z"
}

View file

@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build output
dist/
# Test coverage
coverage/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.local
.env.*.local

View file

@ -0,0 +1,356 @@
# Hindsight Memory Integration for Vercel AI SDK
Give your AI agents persistent, human-like memory using [Hindsight](https://vectorize.io/hindsight) with the [Vercel AI SDK](https://ai-sdk.dev).
## Features
- **Three Memory Operations**: `retain` (store), `recall` (retrieve), and `reflect` (reason over memories)
- **Multi-User Support**: Dynamic bank IDs per call for multi-user/multi-tenant scenarios
- **Full API Coverage**: Complete parameter support for all Hindsight operations
- **Type-Safe**: Full TypeScript support with Zod schemas for validation
- **AI SDK 6 Native**: Works seamlessly with `generateText`, `streamText`, and `ToolLoopAgent`
## Installation
```bash
npm install @vectorize-io/hindsight-ai-sdk ai zod
```
You'll also need a Hindsight client. Choose one:
**Option A: TypeScript/JavaScript Client**
```bash
npm install @vectorize-io/hindsight-client
```
**Option B: Direct HTTP Client** (no additional dependencies)
```typescript
// See "HTTP Client Example" below
```
## Quick Start
### 1. Set up your Hindsight client
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const hindsightClient = new HindsightClient({
apiUrl: process.env.HINDSIGHT_API_URL || 'http://localhost:8000',
});
```
### 2. Create Hindsight tools
```typescript
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
const tools = createHindsightTools({
client: hindsightClient,
});
```
### 3. Use with AI SDK
```typescript
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
prompt: 'Remember that Alice loves hiking and prefers spicy food',
});
console.log(result.text);
```
## Full Example: Memory-Enabled Chatbot
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
// Initialize Hindsight
const hindsightClient = new HindsightClient({
apiUrl: 'http://localhost:8000',
});
const tools = createHindsightTools({ client: hindsightClient });
// Chat with memory
const result = await streamText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
system: `You are a helpful assistant with long-term memory.
IMPORTANT:
- Before answering questions, use the 'recall' tool to check for relevant memories
- When users share important information, use the 'retain' tool to remember it
- For complex questions requiring synthesis, use the 'reflect' tool
- Always pass the user's ID as the bankId parameter
Your memory persists across sessions!`,
prompt: 'Remember that I am Alice and I love hiking',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
```
## API Reference
### `createHindsightTools(options)`
Creates AI SDK tool definitions for Hindsight memory operations.
**Parameters:**
- `options.client`: `HindsightClient` - Hindsight client instance
- `options.retainDescription`: `string` (optional) - Custom description for the retain tool
- `options.recallDescription`: `string` (optional) - Custom description for the recall tool
- `options.reflectDescription`: `string` (optional) - Custom description for the reflect tool
**Returns:** Object with three tools: `retain`, `recall`, and `reflect`
### Tool: `retain`
Store information in long-term memory.
**Parameters:**
- `bankId`: `string` - Memory bank ID (usually the user ID)
- `content`: `string` - Content to store
- `documentId`: `string` (optional) - Document ID for grouping/upserting
- `timestamp`: `string` (optional) - ISO timestamp for when the memory occurred
- `context`: `string` (optional) - Additional context about the memory
**Returns:**
```typescript
{
success: boolean;
itemsCount: number;
}
```
### Tool: `recall`
Search memory for relevant information.
**Parameters:**
- `bankId`: `string` - Memory bank ID
- `query`: `string` - What to search for
- `types`: `string[]` (optional) - Filter by fact types
- `maxTokens`: `number` (optional) - Maximum tokens to return
- `budget`: `'low' | 'mid' | 'high'` (optional) - Processing budget
- `queryTimestamp`: `string` (optional) - Query from a specific time (ISO format)
- `includeEntities`: `boolean` (optional) - Include entity observations
- `includeChunks`: `boolean` (optional) - Include raw chunks
**Returns:**
```typescript
{
results: Array<{
id: string;
text: string;
type?: string;
entities?: string[];
context?: string;
occurred_start?: string;
occurred_end?: string;
mentioned_at?: string;
document_id?: string;
metadata?: Record<string, string>;
chunk_id?: string;
}>;
entities?: Record<string, EntityState>;
}
```
### Tool: `reflect`
Analyze memories to form insights and generate contextual answers.
**Parameters:**
- `bankId`: `string` - Memory bank ID
- `query`: `string` - Question to reflect on
- `context`: `string` (optional) - Additional context for reflection
- `budget`: `'low' | 'mid' | 'high'` (optional) - Processing budget
**Returns:**
```typescript
{
text: string;
basedOn?: Array<{
id?: string;
text: string;
type?: string;
context?: string;
occurred_start?: string;
occurred_end?: string;
}>;
}
```
## Advanced Usage
### Custom Tool Descriptions
Customize tool descriptions to guide model behavior:
```typescript
const tools = createHindsightTools({
client: hindsightClient,
retainDescription: 'Store user preferences and important facts. Always include context.',
recallDescription: 'Search past conversations. Use specific queries for best results.',
reflectDescription: 'Synthesize insights from memories. Use for complex questions.',
});
```
### Multi-User Scenarios
Each tool call accepts a `bankId` parameter, making it easy to support multiple users:
```typescript
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
prompt: `User ID: ${userId}\n\nRemember that I prefer dark mode`,
});
```
The model will automatically pass the user ID to the tools.
### Using with ToolLoopAgent
```typescript
import { ToolLoopAgent, stopWhen, stepCountIs } from 'ai';
const agent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4-20250514'),
tools,
instructions: `You are a personal assistant with long-term memory.
Always check memory before responding using the recall tool.
Store important user preferences with the retain tool.
Use the reflect tool to analyze patterns in the user's behavior.`,
stopWhen: stepCountIs(10),
});
const result = await agent.generate({
prompt: 'What did I say I wanted to work on this week?',
});
```
## HTTP Client Example
If you prefer not to install the full Hindsight client, you can use a simple HTTP client:
```typescript
import type { HindsightClient } from '@vectorize-io/hindsight-ai-sdk';
const httpClient: HindsightClient = {
async retain(bankId, content, options = {}) {
const response = await fetch(`${HINDSIGHT_URL}/v1/default/banks/${bankId}/memories/retain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
timestamp: options.timestamp,
context: options.context,
metadata: options.metadata,
document_id: options.documentId,
async: options.async,
}),
});
return response.json();
},
async recall(bankId, query, options = {}) {
const response = await fetch(`${HINDSIGHT_URL}/v1/default/banks/${bankId}/memories/recall`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
types: options.types,
max_tokens: options.maxTokens,
budget: options.budget,
trace: options.trace,
query_timestamp: options.queryTimestamp,
include_entities: options.includeEntities,
max_entity_tokens: options.maxEntityTokens,
include_chunks: options.includeChunks,
max_chunk_tokens: options.maxChunkTokens,
}),
});
return response.json();
},
async reflect(bankId, query, options = {}) {
const response = await fetch(`${HINDSIGHT_URL}/v1/default/banks/${bankId}/reflect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
context: options.context,
budget: options.budget,
}),
});
return response.json();
},
};
const tools = createHindsightTools({ client: httpClient });
```
## Running Hindsight Locally
The easiest way to run Hindsight for development:
```bash
# Install and run with embedded mode (no setup required)
uvx hindsight-embed@latest -p myapp daemon start
# The API will be available at http://localhost:8000
```
For production deployments, see the [Hindsight Documentation](https://vectorize.io/hindsight).
## TypeScript Types
All types are exported for your convenience:
```typescript
import type {
Budget,
HindsightClient,
HindsightTools,
HindsightToolsOptions,
RecallResult,
RecallResponse,
ReflectFact,
ReflectResponse,
RetainResponse,
EntityState,
ChunkData,
} from '@vectorize-io/hindsight-ai-sdk';
```
## Documentation & Resources
- [Hindsight Documentation](https://vectorize.io/hindsight)
- [Vercel AI SDK Documentation](https://ai-sdk.dev)
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
- [Examples](https://github.com/vectorize-io/hindsight/tree/main/examples)
## License
MIT
## Support
For issues and questions:
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
- Email: support@vectorize.io

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
{
"name": "@vectorize-io/hindsight-ai-sdk",
"version": "0.4.8",
"description": "Hindsight memory integration for Vercel AI SDK - Give your AI agents persistent, human-like memory",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"ai",
"ai-sdk",
"vercel",
"memory",
"hindsight",
"agents",
"llm",
"long-term-memory"
],
"author": "Vectorize <support@vectorize.io>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/ai-sdk"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"ai": "^6.0.0",
"zod": "^3.0.0 || ^4.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/ui": "^4.0.18",
"ai": "^6.0.2",
"typescript": "^5.7.0",
"vitest": "^4.0.18",
"zod": "^4.2.0"
},
"engines": {
"node": ">=22"
}
}

View file

@ -0,0 +1,15 @@
export {
createHindsightTools,
BudgetSchema,
type Budget,
type HindsightClient,
type HindsightTools,
type HindsightToolsOptions,
type RecallResult,
type RecallResponse,
type ReflectFact,
type ReflectResponse,
type RetainResponse,
type EntityState,
type ChunkData,
} from './tools';

View file

@ -0,0 +1,362 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHindsightTools, type HindsightClient } from './index.js';
describe('createHindsightTools', () => {
let mockClient: HindsightClient;
beforeEach(() => {
mockClient = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn(),
};
});
describe('tool creation', () => {
it('should create all three tools', () => {
const tools = createHindsightTools({ client: mockClient });
expect(tools).toHaveProperty('retain');
expect(tools).toHaveProperty('recall');
expect(tools).toHaveProperty('reflect');
expect(typeof tools.retain.execute).toBe('function');
expect(typeof tools.recall.execute).toBe('function');
expect(typeof tools.reflect.execute).toBe('function');
});
it('should use default descriptions when not provided', () => {
const tools = createHindsightTools({ client: mockClient });
expect(tools.retain.description).toContain('Store information in long-term memory');
expect(tools.recall.description).toContain('Search memory for relevant information');
expect(tools.reflect.description).toContain('Analyze memories to form insights');
});
it('should use custom descriptions when provided', () => {
const tools = createHindsightTools({
client: mockClient,
retainDescription: 'Custom retain description',
recallDescription: 'Custom recall description',
reflectDescription: 'Custom reflect description',
});
expect(tools.retain.description).toBe('Custom retain description');
expect(tools.recall.description).toBe('Custom recall description');
expect(tools.reflect.description).toBe('Custom reflect description');
});
});
describe('retain tool', () => {
it('should call client.retain with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 5,
async: false,
});
const result = await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
});
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
documentId: undefined,
timestamp: undefined,
context: undefined,
});
expect(result).toEqual({ success: true, itemsCount: 5 });
});
it('should pass optional parameters to client.retain', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 3,
async: false,
});
await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
context: 'Test context',
});
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
context: 'Test context',
});
});
it('should transform response correctly', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 10,
async: false,
});
const result = await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
});
expect(result).toEqual({ success: true, itemsCount: 10 });
});
});
describe('recall tool', () => {
it('should call client.recall with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.recall).mockResolvedValue({
results: [
{
id: 'fact-1',
text: 'Test fact',
type: 'preference',
},
],
});
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
});
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
types: undefined,
maxTokens: undefined,
budget: undefined,
queryTimestamp: undefined,
includeEntities: undefined,
includeChunks: undefined,
});
expect(result.results).toHaveLength(1);
expect(result.results[0].id).toBe('fact-1');
});
it('should pass all optional parameters to client.recall', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.recall).mockResolvedValue({
results: [],
});
await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
types: ['preference', 'fact'],
maxTokens: 1000,
budget: 'high',
queryTimestamp: '2024-01-01T00:00:00Z',
includeEntities: true,
includeChunks: true,
});
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
types: ['preference', 'fact'],
maxTokens: 1000,
budget: 'high',
queryTimestamp: '2024-01-01T00:00:00Z',
includeEntities: true,
includeChunks: true,
});
});
it('should handle empty results', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.recall).mockResolvedValue({
results: undefined as any,
});
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
});
expect(result.results).toEqual([]);
});
it('should include entities when present', async () => {
const tools = createHindsightTools({ client: mockClient });
const entities = {
'entity-1': {
entity_id: 'entity-1',
canonical_name: 'Alice',
observations: [{ text: 'Alice loves hiking' }],
},
};
vi.mocked(mockClient.recall).mockResolvedValue({
results: [],
entities,
});
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
includeEntities: true,
});
expect(result.entities).toEqual(entities);
});
});
describe('reflect tool', () => {
it('should call client.reflect with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Reflection result',
based_on: [
{
id: 'fact-1',
text: 'Supporting fact',
},
],
});
const result = await tools.reflect.execute({
bankId: 'test-bank',
query: 'What are my preferences?',
});
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'What are my preferences?', {
context: undefined,
budget: undefined,
});
expect(result.text).toBe('Reflection result');
expect(result.basedOn).toHaveLength(1);
});
it('should pass optional parameters to client.reflect', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Reflection result',
});
await tools.reflect.execute({
bankId: 'test-bank',
query: 'What are my preferences?',
context: 'User context',
budget: 'mid',
});
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'What are my preferences?', {
context: 'User context',
budget: 'mid',
});
});
it('should handle empty text response with fallback', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.reflect).mockResolvedValue({
text: undefined as any,
});
const result = await tools.reflect.execute({
bankId: 'test-bank',
query: 'Test query',
});
expect(result.text).toBe('No insights available yet.');
});
it('should include basedOn facts when present', async () => {
const tools = createHindsightTools({ client: mockClient });
const basedOn = [
{
id: 'fact-1',
text: 'User prefers spicy food',
type: 'preference',
},
{
id: 'fact-2',
text: 'User is allergic to nuts',
type: 'health',
},
];
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Based on your history, you prefer spicy Asian cuisine',
based_on: basedOn,
});
const result = await tools.reflect.execute({
bankId: 'test-bank',
query: 'What do I like?',
});
expect(result.basedOn).toEqual(basedOn);
});
});
describe('error handling', () => {
it('should propagate errors from client.retain', async () => {
const tools = createHindsightTools({ client: mockClient });
const error = new Error('Retain failed');
vi.mocked(mockClient.retain).mockRejectedValue(error);
await expect(
tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
})
).rejects.toThrow('Retain failed');
});
it('should propagate errors from client.recall', async () => {
const tools = createHindsightTools({ client: mockClient });
const error = new Error('Recall failed');
vi.mocked(mockClient.recall).mockRejectedValue(error);
await expect(
tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
})
).rejects.toThrow('Recall failed');
});
it('should propagate errors from client.reflect', async () => {
const tools = createHindsightTools({ client: mockClient });
const error = new Error('Reflect failed');
vi.mocked(mockClient.reflect).mockRejectedValue(error);
await expect(
tools.reflect.execute({
bankId: 'test-bank',
query: 'Test query',
})
).rejects.toThrow('Reflect failed');
});
});
describe('budget schema', () => {
it('should accept valid budget values', async () => {
const tools = createHindsightTools({ client: mockClient });
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
for (const budget of ['low', 'mid', 'high'] as const) {
await tools.recall.execute({
bankId: 'test-bank',
query: 'Test',
budget,
});
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test', {
types: undefined,
maxTokens: undefined,
budget,
queryTimestamp: undefined,
includeEntities: undefined,
includeChunks: undefined,
});
}
});
});
});

View file

@ -0,0 +1,559 @@
import { tool } from 'ai';
import { z } from 'zod';
/**
* Budget levels for recall/reflect operations.
*/
export const BudgetSchema = z.enum(['low', 'mid', 'high']);
export type Budget = z.infer<typeof BudgetSchema>;
/**
* Recall result item from Hindsight
*/
export interface RecallResult {
id: string;
text: string;
type?: string | null;
entities?: string[] | null;
context?: string | null;
occurred_start?: string | null;
occurred_end?: string | null;
mentioned_at?: string | null;
document_id?: string | null;
metadata?: Record<string, string> | null;
chunk_id?: string | null;
}
/**
* Entity state with observations
*/
export interface EntityState {
entity_id: string;
canonical_name: string;
observations: Array<{ text: string; mentioned_at?: string | null }>;
}
/**
* Chunk data
*/
export interface ChunkData {
id: string;
text: string;
chunk_index: number;
truncated?: boolean;
}
/**
* Recall response from Hindsight
*/
export interface RecallResponse {
results: RecallResult[];
trace?: Record<string, unknown> | null;
entities?: Record<string, EntityState> | null;
chunks?: Record<string, ChunkData> | null;
}
/**
* Reflect fact
*/
export interface ReflectFact {
id?: string | null;
text: string;
type?: string | null;
context?: string | null;
occurred_start?: string | null;
occurred_end?: string | null;
}
/**
* Reflect response from Hindsight
*/
export interface ReflectResponse {
text: string;
based_on?: ReflectFact[];
}
/**
* Retain response from Hindsight
*/
export interface RetainResponse {
success: boolean;
bank_id: string;
items_count: number;
async: boolean;
}
/**
* Mental model trigger configuration
*/
export interface MentalModelTrigger {
refresh_after_consolidation?: boolean;
}
/**
* Mental model response from Hindsight
*/
export interface MentalModelResponse {
mental_model_id: string;
bank_id: string;
name?: string;
content?: string;
source_query?: string;
tags?: string[];
created_at: string;
updated_at: string;
trigger?: MentalModelTrigger;
}
/**
* Create mental model response from Hindsight
*/
export interface CreateMentalModelResponse {
mental_model_id: string;
bank_id: string;
created_at: string;
}
/**
* Document response from Hindsight
*/
export interface DocumentResponse {
id: string;
bank_id: string;
original_text: string;
content_hash: string | null;
created_at: string;
updated_at: string;
memory_unit_count: number;
tags?: string[];
}
/**
* Directive response from Hindsight
*/
export interface DirectiveResponse {
id: string;
bank_id: string;
name: string;
content: string;
priority: number;
is_active: boolean;
tags: string[];
created_at: string;
updated_at: string;
}
/**
* Create directive response from Hindsight
*/
export interface CreateDirectiveResponse {
id: string;
bank_id: string;
name: string;
content: string;
priority: number;
is_active: boolean;
tags: string[];
created_at: string;
updated_at: string;
}
/**
* Hindsight client interface - matches @vectorize-io/hindsight-client
*/
export interface HindsightClient {
retain(
bankId: string,
content: string,
options?: {
timestamp?: Date | string;
context?: string;
metadata?: Record<string, string>;
documentId?: string;
tags?: string[];
async?: boolean;
}
): Promise<RetainResponse>;
recall(
bankId: string,
query: string,
options?: {
types?: string[];
maxTokens?: number;
budget?: Budget;
trace?: boolean;
queryTimestamp?: string;
includeEntities?: boolean;
maxEntityTokens?: number;
includeChunks?: boolean;
maxChunkTokens?: number;
}
): Promise<RecallResponse>;
reflect(
bankId: string,
query: string,
options?: {
context?: string;
budget?: Budget;
}
): Promise<ReflectResponse>;
createMentalModel(
bankId: string,
options?: {
id?: string;
name?: string;
sourceQuery?: string;
tags?: string[];
maxTokens?: number;
trigger?: MentalModelTrigger;
}
): Promise<CreateMentalModelResponse>;
getMentalModel(
bankId: string,
mentalModelId: string
): Promise<MentalModelResponse>;
getDocument(
bankId: string,
documentId: string
): Promise<DocumentResponse | null>;
createDirective(
bankId: string,
options: {
name: string;
content: string;
priority?: number;
isActive?: boolean;
tags?: string[];
}
): Promise<CreateDirectiveResponse>;
getDirective(
bankId: string,
directiveId: string
): Promise<DirectiveResponse | null>;
listDirectives(
bankId: string,
options?: {
tags?: string[];
tagsMatch?: 'any' | 'all' | 'exact';
activeOnly?: boolean;
limit?: number;
offset?: number;
}
): Promise<{ directives: DirectiveResponse[]; total: number }>;
}
export interface HindsightToolsOptions {
/** Hindsight client instance */
client: HindsightClient;
/**
* Custom description for the retain tool.
*/
retainDescription?: string;
/**
* Custom description for the recall tool.
*/
recallDescription?: string;
/**
* Custom description for the reflect tool.
*/
reflectDescription?: string;
/**
* Custom description for the createMentalModel tool.
*/
createMentalModelDescription?: string;
/**
* Custom description for the queryMentalModel tool.
*/
queryMentalModelDescription?: string;
/**
* Custom description for the getDocument tool.
*/
getDocumentDescription?: string;
}
/**
* Creates AI SDK tools for Hindsight memory operations.
*
* Features:
* - Dynamic bank ID per call (supports multi-user/multi-bank scenarios)
* - Full API parameter support for retain, recall, and reflect
* - Ready to use with streamText, generateText, or ToolLoopAgent
*
* @example
* ```ts
* const tools = createHindsightTools({
* client: hindsightClient,
* });
*
* // Use with AI SDK
* const result = await generateText({
* model: openai('gpt-4'),
* tools,
* prompt: 'Remember that Alice loves hiking',
* });
* ```
*/
export function createHindsightTools({
client,
retainDescription,
recallDescription,
reflectDescription,
createMentalModelDescription,
queryMentalModelDescription,
getDocumentDescription,
}: HindsightToolsOptions) {
const retainParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
content: z.string().describe('Content to store in memory'),
documentId: z.string().optional().describe('Optional document ID for grouping/upserting content'),
timestamp: z.string().optional().describe('Optional ISO timestamp for when the memory occurred'),
context: z.string().optional().describe('Optional context about the memory'),
tags: z.array(z.string()).optional().describe('Optional tags for visibility scoping'),
metadata: z.record(z.string(), z.string()).optional().describe('Optional user-defined metadata'),
});
const recallParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
query: z.string().describe('What to search for in memory'),
types: z.array(z.string()).optional().describe('Filter by fact types'),
maxTokens: z.number().optional().describe('Maximum tokens to return'),
budget: BudgetSchema.optional().describe('Processing budget: low, mid, or high'),
queryTimestamp: z.string().optional().describe('Query from a specific point in time (ISO format)'),
includeEntities: z.boolean().optional().describe('Include entity observations in results'),
includeChunks: z.boolean().optional().describe('Include raw chunks in results'),
});
const reflectParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
query: z.string().describe('Question to reflect on based on memories'),
context: z.string().optional().describe('Additional context for the reflection'),
budget: BudgetSchema.optional().describe('Processing budget: low, mid, or high'),
});
const createMentalModelParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
mentalModelId: z.string().optional().describe('Optional custom ID for the mental model (auto-generated if not provided)'),
name: z.string().optional().describe('Optional name for the mental model'),
sourceQuery: z.string().optional().describe('Query to define what memories to consolidate'),
tags: z.array(z.string()).optional().describe('Optional tags for organizing mental models'),
maxTokens: z.number().optional().describe('Maximum tokens for the mental model content'),
autoRefresh: z.boolean().optional().describe('Auto-refresh mental model after new consolidations (default: false)'),
});
const queryMentalModelParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
mentalModelId: z.string().describe('ID of the mental model to query'),
});
const getDocumentParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
documentId: z.string().describe('ID of the document to retrieve'),
});
const createDirectiveParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
name: z.string().describe('Human-readable name for the directive'),
content: z.string().describe('The directive text to inject into prompts'),
priority: z.number().optional().describe('Higher priority directives are injected first (default 0)'),
isActive: z.boolean().optional().describe('Whether this directive is active (default true)'),
tags: z.array(z.string()).optional().describe('Tags for filtering'),
});
const getDirectiveParams = z.object({
bankId: z.string().describe('Memory bank ID (usually the user ID)'),
directiveId: z.string().describe('ID of the directive to retrieve'),
});
type RetainInput = z.infer<typeof retainParams>;
type RetainOutput = { success: boolean; itemsCount: number };
type RecallInput = z.infer<typeof recallParams>;
type RecallOutput = { results: RecallResult[]; entities?: Record<string, EntityState> | null };
type ReflectInput = z.infer<typeof reflectParams>;
type ReflectOutput = { text: string; basedOn?: ReflectFact[] };
type CreateMentalModelInput = z.infer<typeof createMentalModelParams>;
type CreateMentalModelOutput = { mentalModelId: string; createdAt: string };
type QueryMentalModelInput = z.infer<typeof queryMentalModelParams>;
type QueryMentalModelOutput = { content: string; name?: string; updatedAt: string };
type GetDocumentInput = z.infer<typeof getDocumentParams>;
type GetDocumentOutput = { originalText: string; id: string; createdAt: string; updatedAt: string } | null;
type CreateDirectiveInput = z.infer<typeof createDirectiveParams>;
type CreateDirectiveOutput = { id: string; name: string; content: string; tags: string[]; createdAt: string };
type GetDirectiveInput = z.infer<typeof getDirectiveParams>;
type GetDirectiveOutput = { id: string; name: string; content: string; tags: string[]; isActive: boolean } | null;
return {
retain: tool<RetainInput, RetainOutput>({
description:
retainDescription ??
`Store information in long-term memory. Use this when information should be remembered for future interactions, such as user preferences, facts, experiences, or important context.`,
inputSchema: retainParams,
execute: async (input) => {
console.log('[AI SDK Tool] Retain input:', {
bankId: input.bankId,
documentId: input.documentId,
tags: input.tags,
hasContent: !!input.content,
});
const result = await client.retain(input.bankId, input.content, {
documentId: input.documentId,
timestamp: input.timestamp,
context: input.context,
tags: input.tags,
metadata: input.metadata as Record<string, string> | undefined,
});
return { success: result.success, itemsCount: result.items_count };
},
}),
recall: tool<RecallInput, RecallOutput>({
description:
recallDescription ??
`Search memory for relevant information. Use this to find previously stored information that can help personalize responses or provide context.`,
inputSchema: recallParams,
execute: async (input) => {
const result = await client.recall(input.bankId, input.query, {
types: input.types,
maxTokens: input.maxTokens,
budget: input.budget,
queryTimestamp: input.queryTimestamp,
includeEntities: input.includeEntities,
includeChunks: input.includeChunks,
});
return {
results: result.results ?? [],
entities: result.entities,
};
},
}),
reflect: tool<ReflectInput, ReflectOutput>({
description:
reflectDescription ??
`Analyze memories to form insights and generate contextual answers. Use this to understand patterns, synthesize information, or answer questions that require reasoning over stored memories.`,
inputSchema: reflectParams,
execute: async (input) => {
const result = await client.reflect(input.bankId, input.query, {
context: input.context,
budget: input.budget,
});
return {
text: result.text ?? 'No insights available yet.',
basedOn: result.based_on,
};
},
}),
createMentalModel: tool<CreateMentalModelInput, CreateMentalModelOutput>({
description:
createMentalModelDescription ??
`Create a mental model that automatically consolidates memories into structured knowledge. Mental models are continuously updated as new memories are added, making them ideal for maintaining up-to-date user preferences, behavioral patterns, and accumulated wisdom.`,
inputSchema: createMentalModelParams,
execute: async (input) => {
const result = await client.createMentalModel(input.bankId, {
id: input.mentalModelId,
name: input.name,
sourceQuery: input.sourceQuery,
tags: input.tags,
maxTokens: input.maxTokens,
trigger: input.autoRefresh !== undefined ? { refresh_after_consolidation: input.autoRefresh } : undefined,
});
return {
mentalModelId: result.mental_model_id,
createdAt: result.created_at,
};
},
}),
queryMentalModel: tool<QueryMentalModelInput, QueryMentalModelOutput>({
description:
queryMentalModelDescription ??
`Query an existing mental model to retrieve consolidated knowledge. Mental models provide synthesized insights from memories, making them faster and more efficient than searching through raw memories.`,
inputSchema: queryMentalModelParams,
execute: async (input) => {
const result = await client.getMentalModel(input.bankId, input.mentalModelId);
return {
content: result.content ?? 'No content available yet.',
name: result.name,
updatedAt: result.updated_at,
};
},
}),
getDocument: tool<GetDocumentInput, GetDocumentOutput>({
description:
getDocumentDescription ??
`Retrieve a stored document by its ID. Documents are used to store structured data like application state, user profiles, or any data that needs exact retrieval.`,
inputSchema: getDocumentParams,
execute: async (input) => {
const result = await client.getDocument(input.bankId, input.documentId);
if (!result) {
return null;
}
return {
originalText: result.original_text,
id: result.id,
createdAt: result.created_at,
updatedAt: result.updated_at,
};
},
}),
createDirective: tool<CreateDirectiveInput, CreateDirectiveOutput>({
description:
`Create a directive - a hard rule that is injected into prompts during reflect operations. Directives are explicit instructions that guide agent behavior. Use tags to control when directives are applied (e.g., user-specific directives with 'user:username' tags).`,
inputSchema: createDirectiveParams,
execute: async (input) => {
const result = await client.createDirective(input.bankId, {
name: input.name,
content: input.content,
priority: input.priority,
isActive: input.isActive,
tags: input.tags,
});
return {
id: result.id,
name: result.name,
content: result.content,
tags: result.tags,
createdAt: result.created_at,
};
},
}),
getDirective: tool<GetDirectiveInput, GetDirectiveOutput>({
description:
`Retrieve a directive by its ID. Returns the directive's content, tags, and active status.`,
inputSchema: getDirectiveParams,
execute: async (input) => {
const result = await client.getDirective(input.bankId, input.directiveId);
if (!result) {
return null;
}
return {
id: result.id,
name: result.name,
content: result.content,
tags: result.tags,
isActive: result.is_active,
};
},
}),
};
}
export type HindsightTools = ReturnType<typeof createHindsightTools>;

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

View file

@ -154,6 +154,16 @@ else
print_warn "File $OPENCLAW_PKG not found, skipping"
fi
# Update AI SDK integration
AI_SDK_PKG="hindsight-integrations/ai-sdk/package.json"
if [ -f "$AI_SDK_PKG" ]; then
print_info "Updating $AI_SDK_PKG"
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$AI_SDK_PKG"
rm "${AI_SDK_PKG}.bak"
else
print_warn "File $AI_SDK_PKG not found, skipping"
fi
# Update documentation version (creates new version or syncs to existing)
print_info "Updating documentation for version $VERSION..."
if [ -f "scripts/update-docs-version.sh" ]; then
@ -202,6 +212,7 @@ COMMIT_MSG="Release v$VERSION
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Helm chart"
# Add docs update note