Compare commits

..
9 Commits
Author SHA1 Message Date
Ben a2039581d8 feat(nemoclaw): add @vectorize-io/hindsight-nemoclaw setup CLI (#622)
* feat(nemoclaw): add hindsight-nemoclaw setup CLI package

Automates the full NemoClaw sandbox setup:
- Installs @vectorize-io/hindsight-openclaw plugin
- Configures external API mode in ~/.openclaw/openclaw.json
- Reads current openshell sandbox policy, merges Hindsight egress rule, re-applies
- Restarts the OpenClaw gateway

Options: --dry-run, --skip-policy, --skip-plugin-install
36 unit tests passing

* docs: add NEMOCLAW.md setup guide

* feat(nemoclaw): add README, docs page, and release pipeline
2026-03-20 14:59:29 +01:00
Ben dd14454e38 docs: NemoClaw persistent memory guide and blog post (#621)
* docs(openclaw): add NemoClaw blog
2026-03-19 17:36:01 -04:00
Nicolò Boschi 6fb8c0570f test(openclaw): export stripMemoryTags/extractRecallQuery and add hook integration tests
- Extract stripMemoryTags and extractRecallQuery as exported pure functions
  from index.ts so hooks share one implementation and tests cover the real code
- Update before_agent_start to call extractRecallQuery; update agent_end to
  call stripMemoryTags instead of duplicating the regex inline
- Rewrite index.test.ts to import the real functions (no more local duplicate)
  and add 11 tests for extractRecallQuery covering all envelope-stripping cases
- Add tests/hooks.integration.test.ts: loads the plugin via mock MoltbotPluginAPI
  in HTTP mode, spies on client.recall/retain, and exercises all hook behaviours:
  excluded providers, short messages, memory injection format, tag stripping,
  transcript formatting, array content blocks, metadata, document_id derivation
2026-02-18 13:50:37 +01:00
Nicolò Boschi 587939f337 fix: improve openclaw test coverage 2026-02-18 13:33:15 +01:00
Anton EvseevandClaude Opus 4.6 c461013047 fix(openclaw): shell safety, HTTP dual-mode, lazy reinit, per-user banks (#388)
- exec→execFile: bypass shell entirely, preventing injection via
  special characters in chat history
- HTTP dual-mode: client can now talk directly to the Hindsight API
  via HTTP (setBankMission, retain, recall) when apiUrl is configured,
  bypassing the subprocess/CLI entirely for production deployments
- HindsightClientOptions: replace 5 positional constructor args with
  a typed options object for clarity and extensibility
- sanitize(): strip null bytes from strings — Node 22 rejects them
  in execFile() args
- recall timeout: accept optional timeoutMs parameter for both HTTP
  and subprocess modes; subprocess gets a longer 30s default
- In-flight recall dedup: concurrent recalls for the same bank reuse
  one promise instead of firing duplicate requests
- Timeout/abort handling: graceful warn-level logging instead of
  error spam when recall times out
- Error cause chaining: wrap errors with { cause } for better
  debugging stack traces
- lazyReinit: recover from startup health check failure with 30s
  cooldown and concurrency guard
- Per-user banks: derive bank ID from senderId (not channelId) for
  proper memory isolation per user across channels
- buildClientOptions(): centralized helper replaces 7 duplicated
  constructor call sites

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 13:09:02 +01:00
Nicolò Boschi 7c99feb018 fix(go-client): add go build to CI (#393)
* feat(go-client): add NewAPIClientWithToken helper and expand recall vs reflect FAQ

- Add NewAPIClientWithToken convenience function to Go client for easy authenticated client creation
- Expand FAQ with detailed "When should I use recall vs reflect?" guidance including practical examples

* fix(go-client): add go build to CI and preserve hindsight_client.go in generator

- Add explicit 'go build ./...' step before integration tests for faster compile feedback
- Preserve hindsight_client.go as a maintained file in generate-clients.sh
2026-02-18 13:06:17 +01:00
Nicolò Boschi d06a0259cc feat: improve ai sdk tools (#394) 2026-02-18 13:06:03 +01:00
Eliah RusinandClaude Opus 4.6 be8728b313 fix(go-client): use monorepo-compatible module path (#392)
The Go SDK declared its module as github.com/vectorize-io/hindsight-client-go,
but that repository doesn't exist. Update to
github.com/vectorize-io/hindsight/hindsight-clients/go to match the actual
monorepo path, enabling standard `go get` imports with directory-prefixed tags.

Also enables isGoSubmodule in the OpenAPI generator config and updates all
import references across tests, docs, and the client generation script.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 10:35:13 +01:00
Derek Bouius 917893aac7 fix: restore entity retrieval in recall (#391)
Entity retrieval was removed in ab5e31f2 ("chore: remove dead code")
but the code was not dead — it populated the entities dict and
per-fact entity names returned by the recall endpoint.

This restores:
- fact_entity_map query joining unit_entities and entities tables
- entity_names on each MemoryFact result
- entities_dict with EntityState objects ordered by fact relevance
- entity count in recall log line
2026-02-18 10:33:58 +01:00
62 changed files with 5474 additions and 1337 deletions
+58 -1
View File
@@ -188,6 +188,55 @@ jobs:
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-nemoclaw-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/nemoclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/nemoclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/nemoclaw
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/nemoclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: nemoclaw-integration
path: hindsight-integrations/nemoclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
@@ -487,7 +536,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
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]
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-nemoclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -516,6 +565,12 @@ jobs:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download NemoClaw Integration
uses: actions/download-artifact@v4
with:
name: nemoclaw-integration
path: ./artifacts/nemoclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
@@ -565,6 +620,8 @@ jobs:
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# NemoClaw Integration
cp artifacts/nemoclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Control Plane
+105
View File
@@ -712,6 +712,10 @@ jobs:
sleep 1
done
- name: Build Go client
working-directory: ./hindsight-clients/go
run: go build ./...
- name: Run Go client tests
working-directory: ./hindsight-clients/go
run: go test -v -tags=integration
@@ -722,6 +726,107 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-openclaw-integration:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Install embed dependencies
working-directory: ./hindsight-embed
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Install openclaw integration dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run openclaw integration tests
working-directory: ./hindsight-integrations/openclaw
run: npm run test:integration
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-integration:
runs-on: ubuntu-latest
env:
+1 -9
View File
@@ -272,8 +272,6 @@ ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
@@ -647,9 +645,7 @@ class HindsightConfig:
file_storage_azure_container: str | None # Azure container name (required for azure storage)
file_storage_azure_account_name: str | None # Azure storage account name
file_storage_azure_account_key: str | None # Azure storage account key
file_parser: str # File parser to use (e.g., "markitdown", "iris")
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
file_parser: str # File parser to use (e.g., "markitdown")
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
@@ -716,8 +712,6 @@ class HindsightConfig:
"file_storage_s3_secret_access_key",
"file_storage_gcs_service_account_key",
"file_storage_azure_account_key",
# File parser credentials
"file_parser_iris_token",
}
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
@@ -1036,8 +1030,6 @@ class HindsightConfig:
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_conversion_max_batch_size_mb=int(
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB))
),
@@ -1374,7 +1374,7 @@ class MemoryEngine(MemoryEngineInterface):
logger.debug(f"File storage initialized ({config.file_storage_type})")
# Initialize parser registry
from .parsers import FileParserRegistry, IrisParser, MarkitdownParser
from .parsers import FileParserRegistry, MarkitdownParser
self._parser_registry = FileParserRegistry()
try:
@@ -1382,13 +1382,6 @@ class MemoryEngine(MemoryEngineInterface):
logger.debug("Registered markitdown parser")
except ImportError:
logger.warning("markitdown not available - file parsing disabled")
iris_token = config.file_parser_iris_token
iris_org_id = config.file_parser_iris_org_id
if iris_token and iris_org_id:
self._parser_registry.register(IrisParser(token=iris_token, org_id=iris_org_id))
logger.debug("Registered iris parser")
else:
logger.debug("Iris parser not registered (VECTORIZE_TOKEN or VECTORIZE_ORG_ID not set)")
# Set executor for task backend and initialize
self._task_backend.set_executor(self.execute_task)
@@ -2878,15 +2871,44 @@ class MemoryEngine(MemoryEngineInterface):
)
top_results_dicts.append(result_dict)
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
if include_entities and top_scored:
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
if unit_ids:
async with acquire_with_retry(pool) as entity_conn:
entity_rows = await entity_conn.fetch(
f"""
SELECT ue.unit_id, e.id as entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
unit_ids,
)
for row in entity_rows:
unit_id = str(row["unit_id"])
if unit_id not in fact_entity_map:
fact_entity_map[unit_id] = []
fact_entity_map[unit_id].append(
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
)
# Convert results to MemoryFact objects
memory_facts = []
for result_dict in top_results_dicts:
result_id = str(result_dict.get("id"))
# Get entity names for this fact
entity_names = None
if include_entities and result_id in fact_entity_map:
entity_names = [e["canonical_name"] for e in fact_entity_map[result_id]]
memory_facts.append(
MemoryFact(
id=str(result_dict.get("id")),
id=result_id,
text=result_dict.get("text"),
fact_type=result_dict.get("fact_type", "world"),
entities=None, # Entity observations removed
entities=entity_names,
context=result_dict.get("context"),
occurred_start=result_dict.get("occurred_start"),
occurred_end=result_dict.get("occurred_end"),
@@ -2897,8 +2919,32 @@ class MemoryEngine(MemoryEngineInterface):
)
)
# Entity observations removed - always set to None
# Fetch entity observations if requested
entities_dict = None
total_entity_tokens = 0
if include_entities and fact_entity_map:
# Collect unique entities in order of fact relevance (preserving order from top_scored)
entities_ordered = [] # list of (entity_id, entity_name) tuples
seen_entity_ids = set()
for sr in top_scored:
unit_id = sr.id
if unit_id in fact_entity_map:
for entity in fact_entity_map[unit_id]:
entity_id = entity["entity_id"]
entity_name = entity["canonical_name"]
if entity_id not in seen_entity_ids:
entities_ordered.append((entity_id, entity_name))
seen_entity_ids.add(entity_id)
# Return entities with empty observations (summaries now live in mental models)
entities_dict = {}
for entity_id, entity_name in entities_ordered:
entities_dict[entity_name] = EntityState(
entity_id=entity_id,
canonical_name=entity_name,
observations=[], # Mental models provide this now
)
# Finalize trace if enabled
trace_dict = None
@@ -2909,6 +2955,7 @@ class MemoryEngine(MemoryEngineInterface):
# Log final recall stats
total_time = time.time() - recall_start
num_chunks = len(chunks_dict) if chunks_dict else 0
num_entities = len(entities_dict) if entities_dict else 0
# Include wait times in log if significant
wait_parts = []
if semaphore_wait > 0.01:
@@ -2917,7 +2964,7 @@ class MemoryEngine(MemoryEngineInterface):
wait_parts.append(f"conn={max_conn_wait:.3f}s")
wait_info = f" | waits: {', '.join(wait_parts)}" if wait_parts else ""
log_buffer.append(
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
)
if not quiet:
logger.info("\n" + "\n".join(log_buffer))
@@ -1,10 +1,9 @@
"""File parser implementations."""
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .base import FileParser
from .markitdown import MarkitdownParser
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
__all__ = ["FileParser", "MarkitdownParser", "FileParserRegistry"]
class FileParserRegistry:
@@ -44,8 +43,7 @@ class FileParserRegistry:
ValueError: If no suitable parser found
"""
if name:
# Explicit parser requested — return it directly, let the parser
# raise UnsupportedFileTypeError from convert() if needed
# Explicit parser requested
if name not in self._parsers:
raise ValueError(f"Parser '{name}' not found. Available: {list(self._parsers.keys())}")
return self._parsers[name]
@@ -3,12 +3,6 @@
from abc import ABC, abstractmethod
class UnsupportedFileTypeError(Exception):
"""Raised by a parser when it does not support the given file type."""
pass
class FileParser(ABC):
"""Abstract base for file to markdown parsers."""
@@ -25,27 +19,24 @@ class FileParser(ABC):
Markdown content as string
Raises:
UnsupportedFileTypeError: If the file type is not supported by this parser
RuntimeError: If parsing fails for another reason
ValueError: If file format is not supported
RuntimeError: If parsing fails
"""
pass
@abstractmethod
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""
Check if parser supports this file type.
Override this for local/static extension-based filtering.
Parsers that delegate to a remote service should leave this as True
and raise UnsupportedFileTypeError from convert() instead.
Args:
filename: File name (used for extension check)
content_type: MIME type (optional)
Returns:
True if this parser can handle the file (default: True)
True if this parser can handle the file
"""
return True
pass
@abstractmethod
def name(self) -> str:
@@ -1,137 +0,0 @@
"""Iris parser implementation using the Vectorize Iris HTTP API."""
import asyncio
import logging
import mimetypes
import time
import httpx
from .base import FileParser, UnsupportedFileTypeError
logger = logging.getLogger(__name__)
_IRIS_BASE_URL = "https://api.vectorize.io/v1"
_DEFAULT_POLL_INTERVAL = 2.0 # seconds
_DEFAULT_TIMEOUT = 300.0 # seconds
class IrisParser(FileParser):
"""
Iris file parser using the Vectorize Iris cloud extraction service.
Uploads files to the Vectorize Iris API, starts an extraction job,
and polls until the text is ready. The API determines which file types
are supported — UnsupportedFileTypeError is raised if the file is rejected.
Authentication:
Requires HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN and
HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID environment variables,
or pass them explicitly via the constructor.
"""
def __init__(
self,
token: str,
org_id: str,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
timeout: float = _DEFAULT_TIMEOUT,
):
"""
Initialize iris parser.
Args:
token: Vectorize API token
org_id: Vectorize organization ID
poll_interval: Seconds between status poll requests (default: 2)
timeout: Maximum seconds to wait for extraction (default: 300)
"""
self._token = token
self._org_id = org_id
self._poll_interval = poll_interval
self._timeout = timeout
self._auth_headers = {"Authorization": f"Bearer {token}"}
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to text using the Vectorize Iris API.
Raises:
UnsupportedFileTypeError: If the Iris API rejects the file type (4xx)
RuntimeError: If extraction fails for another reason
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient() as client:
# Step 1: Request a presigned upload URL
init_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
headers=self._auth_headers,
json={"name": filename, "contentType": content_type},
)
_raise_for_status(init_resp, filename, "file upload init")
init_data = init_resp.json()
file_id: str = init_data["fileId"]
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
upload_resp = await client.put(
upload_url,
content=file_data,
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
# Step 3: Start extraction
extract_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction",
headers=self._auth_headers,
json={"fileId": file_id},
)
_raise_for_status(extract_resp, filename, "start extraction")
extraction_id: str = extract_resp.json()["extractionId"]
# Step 4: Poll until ready or timeout
deadline = time.monotonic() + self._timeout
while True:
status_resp = await client.get(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction/{extraction_id}",
headers=self._auth_headers,
)
_raise_for_status(status_resp, filename, "poll extraction status")
status_data = status_resp.json()
if status_data.get("ready"):
data = status_data.get("data", {})
if not data.get("success"):
error = data.get("error", "unknown error")
raise RuntimeError(f"Iris extraction failed for '{filename}': {error}")
text = data.get("text")
if not text:
raise RuntimeError(f"No content extracted from '{filename}'")
return text
if time.monotonic() >= deadline:
raise RuntimeError(f"Iris extraction timed out after {self._timeout}s for '{filename}'")
await asyncio.sleep(self._poll_interval)
def name(self) -> str:
"""Get parser name."""
return "iris"
def _raise_for_status(response: httpx.Response, filename: str, step: str) -> None:
"""
Raise an appropriate error including the response body on HTTP errors.
Raises UnsupportedFileTypeError for 4xx responses (file rejected by the API),
RuntimeError for other HTTP errors.
"""
if not response.is_error:
return
body = response.text or "<empty>"
msg = f"Iris API error during {step} for '{filename}': {response.status_code} {response.reason_phrase}{body}"
if response.is_client_error:
raise UnsupportedFileTypeError(msg)
raise RuntimeError(msg)
-2
View File
@@ -262,8 +262,6 @@ def main():
file_storage_azure_account_name=config.file_storage_azure_account_name,
file_storage_azure_account_key=config.file_storage_azure_account_key,
file_parser=config.file_parser,
file_parser_iris_token=config.file_parser_iris_token,
file_parser_iris_org_id=config.file_parser_iris_org_id,
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
enable_file_upload_api=config.enable_file_upload_api,
-72
View File
@@ -1,72 +0,0 @@
"""
Integration tests for the Iris file parser.
Tests are skipped automatically if HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN
and HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID are not set in the environment.
"""
import os
import pytest
from hindsight_api.config import ENV_FILE_PARSER_IRIS_ORG_ID, ENV_FILE_PARSER_IRIS_TOKEN
from hindsight_api.engine.parsers.iris import IrisParser
_token = os.getenv(ENV_FILE_PARSER_IRIS_TOKEN)
_org_id = os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID)
pytestmark = pytest.mark.skipif(
not (_token and _org_id),
reason="HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN and HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID not set",
)
# Minimal valid PDF with the text "Hello from Hindsight"
_SAMPLE_PDF = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Hello from Hindsight) Tj ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000274 00000 n
trailer << /Size 5 /Root 1 0 R >>
startxref
369
%%EOF"""
@pytest.fixture
def iris_parser() -> IrisParser:
return IrisParser(token=_token, org_id=_org_id)
@pytest.mark.asyncio
async def test_iris_parser_converts_pdf(iris_parser: IrisParser):
"""IrisParser should extract text from a valid PDF."""
result = await iris_parser.convert(_SAMPLE_PDF, "sample.pdf")
assert isinstance(result, str)
assert len(result) > 0
@pytest.mark.asyncio
async def test_iris_parser_name(iris_parser: IrisParser):
"""IrisParser.name() should return 'iris'."""
assert iris_parser.name() == "iris"
+1 -1
View File
@@ -22,7 +22,7 @@ go get golang.org/x/net/context
Put the package under your project folder and add the following in import:
```go
import hindsight "github.com/vectorize-io/hindsight-client-go"
import hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
```
To use a proxy, set the environment variable `HTTP_PROXY`:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/vectorize-io/hindsight-client-go
module github.com/vectorize-io/hindsight/hindsight-clients/go
go 1.18
+17
View File
@@ -0,0 +1,17 @@
package hindsight
// NewAPIClientWithToken creates a new API client configured with a base URL and API token.
// The token is sent as a Bearer token in the Authorization header for all requests.
//
// Example:
//
// client := hindsight.NewAPIClientWithToken("https://api.example.com", "your-api-token")
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithToken(baseURL, token string) *APIClient {
cfg := NewConfiguration()
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
cfg.AddDefaultHeader("Authorization", "Bearer "+token)
return NewAPIClient(cfg)
}
@@ -3,8 +3,8 @@ outputDir: ./
inputSpec: ../../hindsight-docs/static/openapi.json
packageName: hindsight
gitUserId: vectorize-io
gitRepoId: hindsight-client-go
isGoSubmodule: false
gitRepoId: hindsight/hindsight-clients/go
isGoSubmodule: true
enumClassPrefix: true
structPrefix: true
generateInterfaces: true
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_BanksAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_DirectivesAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_DocumentsAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_EntitiesAPIService(t *testing.T) {
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_MemoryAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_MentalModelsAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_MonitoringAPIService(t *testing.T) {
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
openapiclient "github.com/vectorize-io/hindsight-client-go"
openapiclient "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func Test_hindsight_OperationsAPIService(t *testing.T) {
@@ -0,0 +1,285 @@
---
slug: sandboxed-agent-persistent-memory-nemoclaw
title: "Give NemoClaw the Best Agent Memory Available In One Command"
description: Add persistent memory to a NemoClaw sandboxed AI agent without changing code. One command, one network policy, memories survive across sessions.
authors: [hindsight]
date: 2026-03-19
image: /img/blog/2026-03-19/nemoclaw-memory.png
hide_table_of_contents: true
---
## TL;DR
- [NemoClaw](https://nemoclaw.ai) sandboxes isolate AI agents — controlled filesystem, processes, and network. That isolation makes persistent memory harder.
- We connected the `hindsight-openclaw` plugin to a live NemoClaw sandbox using [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup). No code changes — one command.
- External API mode is the natural fit: the plugin becomes a thin HTTP client, and the sandbox only needs one egress rule.
- Memories captured in one session are recalled in the next. The sandbox didn't interfere.
- The pattern generalizes: sandbox controls what the agent can *do*, memory controls what it *knows*. They compose cleanly.
## The Problem: Sandboxed Agents Have No Persistent Memory
AI agents running inside sandboxes present an interesting memory problem. The sandbox is designed to isolate the agent — it controls which files it can read, which processes it can spawn, and which network endpoints it can reach. That isolation is the point. But it creates a question: if every session starts in a clean, constrained environment, where does persistent memory live?
We set out to answer that with [NemoClaw](https://nemoclaw.ai), NVIDIA's sandboxed agent runtime built on OpenShell. The goal was simple: connect the `hindsight-openclaw` plugin to a live NemoClaw sandbox and verify that memories captured in one session are recalled in the next. No code changes allowed — if we needed to modify the plugin to make it work, we'd learned something important about the architecture.
We didn't need to change a line.
<!-- truncate -->
## The Approach: External API Mode for Sandbox Memory
[NemoClaw](https://nemoclaw.ai) runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox. The sandbox enforces a filesystem policy (what paths the agent can read and write), a process policy (what it runs as), and a network egress policy (which outbound endpoints are permitted).
By default, the sandbox ships with policies for the services it needs: the LLM provider, GitHub, npm, the OpenClaw API. Everything else is blocked. That's a good default — an agent that can call arbitrary endpoints is harder to trust.
[Hindsight](https://hindsight.vectorize.io) operates as an external API. The plugin makes HTTPS calls to `api.hindsight.vectorize.io` to [retain and recall memories](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). From the sandbox's perspective, that's just another outbound endpoint — one that needs to be explicitly permitted.
The full stack looks like this:
```
┌─────────────────────────────────────────────┐
│ NemoClaw Sandbox (OpenShell) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ OpenClaw Gateway │ │
│ │ + hindsight-openclaw plugin │ │
│ │ ↓ before_agent_start: recall │ │
│ │ ↓ agent_end: retain │ │
│ └──────────────────────────────────────┘ │
│ │
│ Network egress policy: │
│ ✓ api.anthropic.com │
│ ✓ integrate.api.nvidia.com │
│ ✓ api.hindsight.vectorize.io ← added │
└─────────────────────────────────────────────┘
```
When the plugin retains a conversation, Hindsight doesn't just store raw text. It extracts structured facts, resolves entities, builds a [knowledge graph](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs), and indexes everything for multi-strategy retrieval — semantic search, BM25 keyword matching, graph traversal, and temporal filtering with [cross-encoder reranking](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). That's what makes recall useful even when the agent's question doesn't match the exact wording of what was stored.
The plugin has two modes. In **local daemon mode**, it spawns a local `hindsight-embed` process and communicates with it over a local port. In **external API mode**, it skips the daemon entirely and makes HTTP calls directly to a Hindsight Cloud endpoint.
Inside a sandbox, local daemon mode is awkward. The sandbox controls which processes can be spawned, and a background daemon that launches `uvx` subprocesses is friction we don't need. External API mode is the natural fit: the plugin becomes a thin HTTP client, and the only infrastructure requirement is a network egress rule.
For background on the OpenClaw plugin itself — how it hooks into the gateway lifecycle, auto-injects memory into context, and prevents feedback loops — see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
## Implementation: One Command
The `hindsight-nemoclaw` package automates the entire setup — installing the plugin, configuring external API mode, reading your current sandbox policy, merging the Hindsight egress rule, and restarting the gateway:
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
That's it. You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
Use `--dry-run` to preview all changes before applying. Use `--skip-policy` if you manage sandbox policies manually.
## Verifying It Works
After setup, the gateway logs confirm the plugin is running:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
Send a message to the agent:
```bash
openclaw agent --agent main --session-id session-1 \
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
```
The gateway logs show the hooks firing:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
Open a fresh session and ask what the agent remembers:
```bash
openclaw agent --agent main --session-id session-2 \
-m "What do you remember about me?"
```
```
Right now I've just got the basics: your name is Ben, you're working on
Hindsight, and you like commit messages to be detailed. If there's anything
else you want me to keep in mind, let me know.
```
The memory survived the session boundary. The sandbox didn't interfere with it.
## What the Setup Command Does (Manual Alternative)
If you prefer to apply the steps yourself, here's what `hindsight-nemoclaw setup` does under the hood.
**Install the plugin:**
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
**Configure `~/.openclaw/openclaw.json`:**
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
**Add the Hindsight block to your sandbox network policy** (note: `openshell policy set` replaces the full document — include all existing policies):
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Pitfalls & Edge Cases
### 1. Policy replacement is full-document
`openshell policy set` replaces the entire policy document, not just the section you're adding. The `hindsight-nemoclaw setup` command handles this automatically — it reads the current policy, merges the Hindsight block, and re-applies the full document. If you're applying manually, make sure your YAML includes all existing network policies.
### 2. LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent with a restricted security context that can't access `~/Documents` or other user directories. `openclaw plugins install --link` creates a symlink that the LaunchAgent can't follow — install as a copy instead:
```bash
# This works — copies files to ~/.openclaw/extensions/
openclaw plugins install @vectorize-io/hindsight-openclaw
```
If you see `EPERM: operation not permitted, scandir` in your gateway logs, this is what's happening.
### 3. Memory retention is asynchronous
When the plugin calls `retain` at the end of a session, [fact extraction and entity resolution](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs) happen in the background on Hindsight's side. If you open a new session immediately, the most recent memories may not be indexed yet. In practice this is a few seconds — but it's worth knowing if you're testing back-to-back.
### 4. Binary-scoped egress is strict
The `binaries` field in the network policy means *only* the specified executable can reach the endpoint. If you update OpenClaw and the binary path changes, the egress rule silently stops working. Check your binary path after upgrades.
## Tradeoffs: External API vs. Local Daemon in a Sandbox
| | **External API mode** | **Local daemon mode** |
|---|---|---|
| **Setup** | One command | Process spawning permissions |
| **Dependencies** | HTTPS egress only | `uvx`, Python, local PostgreSQL |
| **Data location** | Hindsight Cloud | Local to sandbox |
| **Multi-sandbox sharing** | Same bank from anywhere | Per-sandbox only |
| **Sandbox compatibility** | Clean fit | Fights the process policy |
**Use external API mode** when you're in a sandbox, want shared memory across instances, or don't want to manage a local database.
**Use local daemon mode** when data must stay on the machine, network egress is completely locked down, or you're running outside a sandbox where process spawning is unrestricted.
For background on the local daemon approach, see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
## What This Pattern Means for Sandboxed Agent Memory
The pattern here is worth naming. A sandboxed agent isn't a limitation on persistent memory — it's a different trust boundary:
- **Sandbox** controls what the agent can *do* — filesystem access, process spawning, network calls.
- **Memory** controls what the agent *knows* — facts, entities, context from prior sessions.
Those are orthogonal concerns, and they compose cleanly.
By keeping memory in an external service and making the network policy explicit, you get both: an agent that's constrained in what it can affect, and one that builds durable knowledge across sessions. The policy file is a readable record of every external dependency the agent has. That transparency is useful.
There's also an interesting property of `dynamicBankId`:
- **Enabled** (`true`): each user gets an isolated memory bank. Memories from one user's sessions can't bleed into another's. Use this for multi-tenant deployments.
- **Disabled** (`false`): a shared bank accumulates context from all sessions. Use this for single-user sandboxes like a personal coding assistant.
> **Want to skip self-hosting?** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) is what we used in this walkthrough — no Docker, no infrastructure. Sign up, grab an API key, and run `npx @vectorize-io/hindsight-nemoclaw setup`.
## Recap
Persistent memory in a sandboxed AI agent is one command: `npx @vectorize-io/hindsight-nemoclaw setup`. It installs the plugin, applies the network egress rule, and configures external API mode — everything the sandbox needs to let Hindsight through.
The key insight: sandbox isolation and persistent memory are orthogonal concerns. The sandbox controls what the agent can affect; memory controls what the agent knows. One network policy rule bridges them without compromising either.
## Next Steps
- **Run the setup**: `npx @vectorize-io/hindsight-nemoclaw setup --help` to get started.
- **Try per-user memory banks**: Enable `dynamicBankId: true` to give each user isolated memory in multi-tenant deployments.
- **Explore the OpenClaw plugin in depth**: See [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight) for how the plugin hooks into gateway lifecycle events.
- **Connect other agents to the same memory**: Hindsight works with [Hermes Agent](https://hindsight.vectorize.io/blog/2026/03/17/hermes-agent-memory), [Streamlit chatbots](https://hindsight.vectorize.io/blog/2026/03/17/python-chatbot-memory-streamlit), and [any MCP client](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory).
- **Check out the docs**: Full API reference and SDK guides at [docs.hindsight.vectorize.io](https://docs.hindsight.vectorize.io/recall/).
---
**Resources:**
- [hindsight-nemoclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-nemoclaw)
- [hindsight-openclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-openclaw)
- [OpenClaw plugin documentation](https://vectorize.io/hindsight/sdks/integrations/openclaw)
- [Hindsight Cloud](https://ui.hindsight.vectorize.io)
+4 -26
View File
@@ -68,7 +68,7 @@ Hindsight supports three PostgreSQL vector extensions:
#### **pgvector** (HNSW - default)
- In-memory index using Hierarchical Navigable Small World algorithm
- Works well for most embeddings and dataset sizes
- Fast for small-medium datasets (<10M vectors)
- Fast for small-medium datasets (&lt;10M vectors)
- Higher memory usage for large datasets
- Most widely deployed and supported
@@ -97,7 +97,7 @@ Hindsight supports three PostgreSQL vector extensions:
- When disk I/O is not a bottleneck
**When to use pgvector (HNSW):**
- Small-medium datasets (<10M vectors)
- Small-medium datasets (&lt;10M vectors)
- Maximum query speed when all data fits in memory
- Simple nearest-neighbor queries without filters
- Standard PostgreSQL deployment preference
@@ -636,34 +636,12 @@ Configuration for the file upload and conversion pipeline (used by `POST /v1/def
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_FILE_UPLOAD_API` | Enable the file upload API endpoint | `true` |
| `HINDSIGHT_API_FILE_PARSER` | File parser to use (`markitdown`, `iris`) | `markitdown` |
| `HINDSIGHT_API_FILE_PARSER` | File parser to use (`markitdown`) | `markitdown` |
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE` | Max files per upload request | `10` |
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB` | Max total upload size per request (MB) | `100` |
| `HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN` | Delete stored files after memory extraction completes | `true` |
#### Parser: markitdown (default)
Local file-to-markdown conversion using [Microsoft's markitdown](https://github.com/microsoft/markitdown). No external service required.
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG — OCR), audio (MP3, WAV — transcription), HTML, TXT, MD, CSV.
#### Parser: iris
Cloud-based extraction via [Vectorize Iris](https://docs.vectorize.io/build-deploy/extract-information/understanding-iris/). Higher quality extraction for complex documents, powered by a remote AI service.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN` | Vectorize API token | — |
| `HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID` | Vectorize organization ID | — |
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, JPEG, PNG, GIF, BMP, TIFF, WEBP), HTML, TXT, MD, CSV.
```bash
# Use iris parser (requires Vectorize account)
export HINDSIGHT_API_FILE_PARSER=iris
export HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN=your-vectorize-token
export HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID=your-org-id
```
**Supported formats (via markitdown):** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF — OCR), audio (MP3, WAV — transcription), HTML, TXT, MD, CSV, and more.
```bash
# Increase batch limits for large file imports
+1 -1
View File
@@ -12,7 +12,7 @@ import quickstartGo from '!!raw-loader!@site/examples/api/quickstart.go';
## Installation
```bash
go get github.com/vectorize-io/hindsight-client-go
go get github.com/vectorize-io/hindsight/hindsight-clients/go
```
Requires Go 1.23+.
@@ -1,366 +0,0 @@
---
sidebar_position: 4
---
# Vercel AI SDK
Official Hindsight integration for the [Vercel AI SDK](https://ai-sdk.dev).
## Features
- **7 Memory Tools**: Core memory operations (retain, recall, reflect), mental models (create, query), documents (get), and directives (create)
- **AI SDK 6 Native**: Works seamlessly with `generateText`, `streamText`, and `ToolLoopAgent`
- **Multi-User Support**: Dynamic bank IDs per tool call for multi-user/multi-tenant scenarios
- **Full Parameter Support**: Complete access to all Hindsight API parameters
- **Type-Safe**: Full TypeScript support with Zod schemas for validation
## Installation
```bash
npm install @vectorize-io/hindsight-ai-sdk @vectorize-io/hindsight-client ai zod
```
## 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);
```
## Memory Tools
The integration provides seven tools that the AI model can use to manage memory:
### `retain` - Store Information
The model calls this tool to store information for future recall.
**Parameters:**
- `bankId` (required): Memory bank ID (usually the user ID)
- `content` (required): Content to store
- `documentId` (optional): Document ID for grouping/upserting related memories
- `timestamp` (optional): ISO timestamp for when the memory occurred
- `context` (optional): Additional context about the memory
- `metadata` (optional): Key-value metadata for filtering
**Example tool call:**
```typescript
{
bankId: "user-123",
content: "Alice loves hiking and goes to Yosemite every summer",
context: "User preferences",
timestamp: "2024-01-15T10:30:00Z"
}
```
**Returns:**
```typescript
{
success: true,
itemsCount: 1
}
```
### `recall` - Search Memories
The model calls this tool to search for relevant information in memory.
**Parameters:**
- `bankId` (required): Memory bank ID
- `query` (required): What to search for
- `types` (optional): Filter by fact types (`['world', 'experience', 'opinion']`)
- `maxTokens` (optional): Maximum tokens to return (default: 4096)
- `budget` (optional): Processing budget - `'low'`, `'mid'`, or `'high'`
- `queryTimestamp` (optional): Query from a specific time (ISO format)
- `includeEntities` (optional): Include entity observations
- `includeChunks` (optional): Include raw document chunks
**Example tool call:**
```typescript
{
bankId: "user-123",
query: "What does Alice like to do outdoors?",
types: ["world", "experience"],
maxTokens: 2048,
budget: "mid"
}
```
**Returns:**
```typescript
{
results: [
{
id: "mem-123",
text: "Alice loves hiking",
type: "world",
entities: ["Alice"],
context: "User preferences",
occurred_start: "2024-01-15T10:30:00Z",
document_id: "doc-456",
metadata: { source: "chat" }
}
],
entities: {
"Alice": {
canonical_name: "Alice",
mention_count: 15,
observations: [...]
}
}
}
```
### `reflect` - Synthesize Insights
The model calls this tool to analyze memories and generate contextual insights.
**Parameters:**
- `bankId` (required): Memory bank ID
- `query` (required): Question to reflect on
- `context` (optional): Additional context for reflection
- `budget` (optional): Processing budget - `'low'`, `'mid'`, or `'high'`
**Example tool call:**
```typescript
{
bankId: "user-123",
query: "What outdoor activities does Alice enjoy?",
context: "Planning a weekend trip",
budget: "mid"
}
```
**Returns:**
```typescript
{
text: "Alice is an avid hiker who particularly enjoys visiting Yosemite National Park during summer months. She has expressed strong preferences for mountain trails over beach activities.",
basedOn: [
{
id: "mem-123",
text: "Alice loves hiking",
type: "world",
context: "User preferences",
occurred_start: "2024-01-15T10:30:00Z"
}
]
}
```
### `createMentalModel` - Create Knowledge Consolidation
The model calls this tool to create a mental model that automatically consolidates memories into structured knowledge.
**Parameters:**
- `bankId` (required): Memory bank ID
- `mentalModelId` (optional): Custom ID for the mental model (auto-generated if not provided)
- `name` (optional): Name for the mental model
- `sourceQuery` (optional): Query defining which memories to consolidate
- `tags` (optional): Tags for organizing mental models
- `maxTokens` (optional): Maximum tokens for the content
- `autoRefresh` (optional): Auto-refresh after new consolidations (default: false)
**Example tool call:**
```typescript
{
bankId: "user-123",
name: "User Preferences",
sourceQuery: "What are the user's preferences?",
tags: ["preferences"],
autoRefresh: true
}
```
**Returns:**
```typescript
{
mentalModelId: "mm-456",
createdAt: "2024-01-15T10:30:00Z"
}
```
### `queryMentalModel` - Retrieve Consolidated Knowledge
The model calls this tool to retrieve synthesized insights from an existing mental model.
**Parameters:**
- `bankId` (required): Memory bank ID
- `mentalModelId` (required): ID of the mental model to query
**Example tool call:**
```typescript
{
bankId: "user-123",
mentalModelId: "mm-456"
}
```
**Returns:**
```typescript
{
content: "The user prefers outdoor activities, particularly hiking. They enjoy mountain trails and visit Yosemite regularly during summer.",
name: "User Preferences",
updatedAt: "2024-01-20T15:45:00Z"
}
```
### `getDocument` - Retrieve Stored Document
The model calls this tool to retrieve a stored document by its ID.
**Parameters:**
- `bankId` (required): Memory bank ID
- `documentId` (required): ID of the document to retrieve
**Example tool call:**
```typescript
{
bankId: "user-123",
documentId: "doc-789"
}
```
**Returns:**
```typescript
{
originalText: "User profile: Alice, Software Engineer, loves hiking...",
id: "doc-789",
createdAt: "2024-01-10T09:00:00Z",
updatedAt: "2024-01-15T14:30:00Z"
}
```
### `createDirective` - Create Behavioral Rule
The model calls this tool to create a directive—a hard rule injected into prompts during reflect operations.
**Parameters:**
- `bankId` (required): Memory bank ID
- `name` (required): Human-readable name for the directive
- `content` (required): The directive text to inject
- `priority` (optional): Higher priority directives are injected first (default: 0)
- `isActive` (optional): Whether this directive is active (default: true)
- `tags` (optional): Tags for filtering (e.g., user-specific directives)
**Example tool call:**
```typescript
{
bankId: "user-123",
name: "Response Format",
content: "Always provide responses in bullet-point format",
priority: 10,
tags: ["formatting"]
}
```
**Returns:**
```typescript
{
id: "dir-321",
name: "Response Format",
content: "Always provide responses in bullet-point format",
tags: ["formatting"],
createdAt: "2024-01-15T10:30:00Z"
}
```
## Usage Examples
### Using with `generateText`
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const hindsightClient = new HindsightClient({
apiUrl: 'http://localhost:8000',
});
const tools = createHindsightTools({ client: hindsightClient });
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
system: `You are a helpful assistant with long-term memory. Use the recall tool to check for relevant memories before responding.`,
prompt: 'Remember that Alice loves hiking and prefers spicy food',
});
console.log(result.text);
```
### Using with `streamText`
```typescript
import { streamText } from 'ai';
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
system: `You have persistent memory. Use retain to store important information and recall to retrieve it.`,
prompt: 'What do you know about Alice?',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
```
### 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 recall before responding and use retain to store important information.`,
stopWhen: stepCountIs(10),
});
const result = await agent.generate({
prompt: 'What did I say I wanted to work on this week?',
});
```
### Multi-User Support
```typescript
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools,
system: `You are a helpful assistant. The user's ID is: ${userId}. Always pass this as the bankId parameter to memory tools.`,
prompt: 'Remember that I prefer dark mode',
});
```
@@ -0,0 +1,97 @@
---
sidebar_position: 4
---
# Vercel AI SDK
The `@vectorize-io/hindsight-ai-sdk` package integrates [Hindsight](https://hindsight.vectorize.io) memory with the [Vercel AI SDK](https://ai-sdk.dev). It provides five ready-to-use tools for retaining, recalling, and reflecting on long-term memories.
import CodeSnippet from '@site/src/components/CodeSnippet';
import aiSdkTs from '!!raw-loader!@site/examples/integrations/ai-sdk.ts';
## Installation
```bash
npm install @vectorize-io/hindsight-ai-sdk @vectorize-io/hindsight-client ai
```
## Setup
Create a Hindsight client and pass it to `createHindsightTools` along with a `bankId`. The `bankId` identifies the memory store for this session—typically a user ID.
<CodeSnippet code={aiSdkTs} section="setup" language="typescript" />
:::tip Per-request bank IDs
In multi-user applications, create `tools` inside your request handler so each request closes over the correct `bankId`. See the [Next.js example](#in-a-nextjs-route-handler) below.
:::
## Usage
### With `generateText`
<CodeSnippet code={aiSdkTs} section="generate-text" language="typescript" />
### With `streamText`
<CodeSnippet code={aiSdkTs} section="stream-text" language="typescript" />
### With `ToolLoopAgent`
<CodeSnippet code={aiSdkTs} section="tool-loop-agent" language="typescript" />
### In a Next.js Route Handler
<CodeSnippet code={aiSdkTs} section="next-api-route" language="typescript" />
---
## Tools Reference
Five tools are registered. The `bankId` is fixed at creation time—the agent cannot change it.
| Tool | What the agent provides | What the constructor controls |
|------|------------------------|-------------------------------|
| `retain` | `content`, `documentId`, `timestamp`, `context` | `async`, `tags`, `metadata` |
| `recall` | `query`, `queryTimestamp` | `budget`, `types`, `maxTokens`, `includeEntities`, `includeChunks` |
| `reflect` | `query`, `context` | `budget` |
| `getMentalModel` | `mentalModelId` | — |
| `getDocument` | `documentId` | — |
**Why this split?** Semantic inputs (what to remember, what to search for) belong to the agent. Infrastructure concerns (cost budget, tagging strategy, async mode) belong to the application.
---
## Constructor Options
All options except `client` and `bankId` are optional. Each tool's options are grouped under the tool name.
<CodeSnippet code={aiSdkTs} section="constructor-options" language="typescript" />
### `retain`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `async` | `boolean` | `false` | Fire-and-forget — do not wait for ingestion to complete |
| `tags` | `string[]` | — | Tags attached to every retained memory |
| `metadata` | `Record<string, string>` | — | Metadata attached to every retained memory |
| `description` | `string` | built-in | Override the tool description shown to the model |
### `recall`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Controls retrieval depth and latency |
| `types` | `('world' \| 'experience' \| 'observation')[]` | all | Restrict results to these fact types |
| `maxTokens` | `number` | API default | Cap the total tokens returned |
| `includeEntities` | `boolean` | `false` | Include entity observations in results |
| `includeChunks` | `boolean` | `false` | Include raw source chunks in results |
| `description` | `string` | built-in | Override the tool description shown to the model |
### `reflect`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Controls synthesis depth and latency |
| `maxTokens` | `number` | API default | Maximum tokens for the response |
| `description` | `string` | built-in | Override the tool description shown to the model |
@@ -0,0 +1,246 @@
---
sidebar_position: 5
---
# NemoClaw
Persistent memory for [NemoClaw](https://nemoclaw.ai) sandboxed agents using [Hindsight](https://hindsight.vectorize.io).
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with controlled filesystem, process, and network egress policies. The `hindsight-nemoclaw` package automates adding Hindsight memory to a sandbox in one command — no code changes required.
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
## How It Works
### The sandbox problem
OpenShell enforces strict network egress — every outbound endpoint must be explicitly permitted in the sandbox policy. By default, the Hindsight API (`api.hindsight.vectorize.io`) is not in that list.
The `hindsight-openclaw` plugin supports **external API mode**, where it skips the local daemon entirely and makes direct HTTPS calls to Hindsight Cloud. This is the natural fit for sandboxed environments: the plugin becomes a thin HTTP client, and the only sandbox change needed is one egress rule.
### What the setup command does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads the current sandbox policy, merges the Hindsight egress block, and re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
### Memory flow
Once set up, the `hindsight-openclaw` plugin hooks into the OpenClaw gateway lifecycle:
- **`before_agent_start`** — recalls relevant memories from past sessions and injects them into context
- **`agent_end`** — retains the conversation to the Hindsight memory bank
The sandbox doesn't interfere with either step — it sees the Hindsight calls as normal HTTPS egress to a permitted endpoint.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
Use `--dry-run` to preview all changes before applying anything. Use `--skip-policy` if you manage sandbox policies manually.
## Manual Setup
If you prefer to apply the steps yourself instead of using the CLI:
### 1. Install the plugin
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### 2. Configure `~/.openclaw/openclaw.json`
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
`llmProvider: "claude-code"` uses the Claude Code process already present in the sandbox — no additional API key needed.
### 3. Add the Hindsight network policy
`openshell policy set` replaces the entire policy document. Export your current policy first, add the Hindsight block, then re-apply:
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| `hindsightApiUrl` | string | — | Hindsight API base URL |
| `hindsightApiToken` | string | — | API token for authentication |
| `llmProvider` | string | auto-detect | LLM provider for memory extraction |
| `dynamicBankId` | boolean | `false` | Isolate memory per user (`true`) or share across sessions (`false`) |
| `bankIdPrefix` | string | `"nemoclaw"` | Prefix for the memory bank name |
### Bank naming
When `dynamicBankId: false`, all sessions write to a single bank named `{bankIdPrefix}-openclaw`. When `dynamicBankId: true`, each user gets an isolated bank — useful for multi-tenant deployments.
## Verifying It Works
After setup, check the gateway logs:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
```
On startup you should see:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
After a conversation:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
## Pitfalls
### Policy replacement is full-document
`openshell policy set` replaces the entire policy document. The `hindsight-nemoclaw setup` command handles this automatically. If you're applying manually, export the current policy first so existing rules aren't lost.
### LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent under a restricted security context. `openclaw plugins install --link` creates a symlink the LaunchAgent can't follow — the setup command installs as a copy instead. If you see `EPERM: operation not permitted, scandir` in gateway logs, this is the cause.
### Memory retention is asynchronous
Fact extraction and entity resolution happen in the background after `retain`. If you open a new session immediately after closing one, the most recent memories may not be indexed yet — typically a few seconds.
### Binary-scoped egress
The `binaries` field in the network policy restricts the egress rule to a specific executable path. If OpenClaw updates and the binary path changes, the rule silently stops working. Check your binary path after upgrades.
## Troubleshooting
### Plugin not loading
```bash
openclaw plugins list | grep hindsight
# Should show: ✓ enabled │ Hindsight Memory │ ...
# Reinstall
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### Egress blocked
If calls to `api.hindsight.vectorize.io` are being blocked, check the active sandbox policy:
```bash
openshell sandbox get my-assistant
```
Verify the `hindsight` block is present and the `binaries` path matches your OpenClaw binary:
```bash
which openclaw
```
### External API not connecting
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# If you see daemon startup messages instead of "Using external API",
# the plugin config isn't being read — check ~/.openclaw/openclaw.json
```
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os"
"time"
hindsight "github.com/vectorize-io/hindsight-client-go"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
@@ -0,0 +1,122 @@
/**
* Hindsight AI SDK integration examples
* These snippets are embedded in the documentation via CodeSnippet.
*/
// [docs:setup]
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
const tools = createHindsightTools({
client,
bankId: 'user-123',
});
// [/docs:setup]
// [docs:generate-text]
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
const { text } = await generateText({
model: openai('gpt-4o'),
tools,
maxSteps: 5,
system: 'You are a helpful assistant with long-term memory.',
prompt: 'Remember that I prefer dark mode and large fonts.',
});
// [/docs:generate-text]
// [docs:stream-text]
import { streamText } from 'ai';
const result = streamText({
model: openai('gpt-4o'),
tools,
maxSteps: 5,
system: 'You are a helpful assistant with long-term memory.',
prompt: 'What are my display preferences?',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
// [/docs:stream-text]
// [docs:tool-loop-agent]
import { generateText, ToolLoopAgent, stepCountIs } from 'ai';
import { openai } from '@ai-sdk/openai';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
const client = new HindsightClient({ baseUrl: process.env.HINDSIGHT_API_URL! });
const agent = new ToolLoopAgent({
model: openai('gpt-4o'),
tools: createHindsightTools({ client, bankId: 'user-123' }),
stopWhen: stepCountIs(10),
system: 'You are a helpful assistant with long-term memory.',
});
const result = await agent.generate({
prompt: 'Remember that my favorite editor is Neovim',
});
// [/docs:tool-loop-agent]
// [docs:next-api-route]
// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { createHindsightTools } from '@vectorize-io/hindsight-ai-sdk';
const hindsightClient = new HindsightClient({
baseUrl: process.env.HINDSIGHT_API_URL!,
});
export async function POST(req: Request) {
const { messages, userId } = await req.json();
// Tools are created per-request, closing over the current user's bankId
const tools = createHindsightTools({
client: hindsightClient,
bankId: userId,
});
return streamText({
model: openai('gpt-4o'),
tools,
maxSteps: 5,
system: 'You are a helpful assistant with long-term memory.',
messages,
}).toDataStreamResponse();
}
// [/docs:next-api-route]
// [docs:constructor-options]
const tools = createHindsightTools({
client,
bankId: userId,
retain: {
async: true, // fire-and-forget (default: false)
tags: ['env:prod', 'app:support'], // always attached to every retained memory
metadata: { version: '2.0' }, // always attached to every retained memory
},
recall: {
budget: 'high', // processing depth: low | mid | high (default: 'mid')
types: ['experience', 'world'], // restrict to these fact types (default: all)
maxTokens: 2048, // cap token budget (default: API default)
includeEntities: true, // include entity observations (default: false)
includeChunks: true, // include raw source chunks (default: false)
},
reflect: {
budget: 'mid', // processing depth (default: 'mid')
},
});
// [/docs:constructor-options]
+30
View File
@@ -123,6 +123,36 @@ See [Operations](/developer/api/operations) for API details.
---
### When should I use recall vs reflect?
**Use recall when:**
- You want raw facts to feed into your own reasoning or prompt
- You need maximum control over how memories are interpreted
- You're doing simple fact lookup (e.g., "What did Alice say about X?")
- Latency is critical — recall is significantly faster (50-500ms vs 1-10s)
- You want to build your own answer synthesis layer on top of retrieved memories
**Use reflect when:**
- You want a ready-to-use answer generated from memories (no extra LLM call needed)
- You need disposition-aware responses shaped by the bank's personality traits (skepticism, literalism, empathy)
- The query requires multi-step reasoning across facts, observations, and mental models
- You need structured output (via `response_schema`) from memory-grounded reasoning
- You want citations — reflect returns which memories, mental models, and directives informed the answer
**Key difference**: Recall returns data; reflect returns an answer. Recall gives you raw materials, reflect does the reasoning for you using the bank's disposition and an autonomous search loop.
```
recall("What food does Alice like?")
→ ["Alice loves sushi", "Alice prefers vegetarian options"] # raw facts
reflect("What should I order for Alice?")
→ "I'd recommend a vegetarian sushi platter — Alice loves sushi and prefers vegetarian options." # grounded answer
```
See [Recall](/developer/api/recall) and [Reflect](/developer/reflect) for full API details.
---
### When should I use mental models?
**Mental models** are consolidated knowledge patterns synthesized from individual facts over time. Use them when you need:
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

@@ -9,7 +9,7 @@ Official Go client for the Hindsight API, built on [ogen](https://github.com/oge
## Installation
```bash
go get github.com/vectorize-io/hindsight-client-go
go get github.com/vectorize-io/hindsight/hindsight-clients/go
```
Requires Go 1.25+.
@@ -24,7 +24,7 @@ import (
"fmt"
"log"
hindsight "github.com/vectorize-io/hindsight-client-go"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
@@ -53,7 +53,7 @@ func main() {
## Client Initialization
```go
import hindsight "github.com/vectorize-io/hindsight-client-go"
import hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
// Default client
client, err := hindsight.New("http://localhost:8888")
@@ -231,7 +231,7 @@ resp, _ := client.Reflect(ctx, "my-bank", "Summarize project X",
For operations not covered by the high-level wrapper (documents, entities, mental models, directives, operations), access the generated ogen client directly:
```go
import "github.com/vectorize-io/hindsight-client-go/internal/ogenapi"
import "github.com/vectorize-io/hindsight/hindsight-clients/go/internal/ogenapi"
ogen := client.OgenClient()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@vectorize-io/hindsight-ai-sdk",
"version": "0.4.8",
"version": "0.4.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vectorize-io/hindsight-ai-sdk",
"version": "0.4.8",
"version": "0.4.11",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.0.0",
@@ -13,31 +13,34 @@ describe('createHindsightTools', () => {
});
describe('tool creation', () => {
it('should create all three tools', () => {
const tools = createHindsightTools({ client: mockClient });
it('should create all tools', () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
expect(tools).toHaveProperty('retain');
expect(tools).toHaveProperty('recall');
expect(tools).toHaveProperty('reflect');
expect(tools).toHaveProperty('getMentalModel');
expect(tools).toHaveProperty('getDocument');
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 });
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
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', () => {
it('should use custom descriptions from nested options', () => {
const tools = createHindsightTools({
client: mockClient,
retainDescription: 'Custom retain description',
recallDescription: 'Custom recall description',
reflectDescription: 'Custom reflect description',
bankId: 'test-bank',
retain: { description: 'Custom retain description' },
recall: { description: 'Custom recall description' },
reflect: { description: 'Custom reflect description' },
});
expect(tools.retain.description).toBe('Custom retain description');
@@ -47,8 +50,8 @@ describe('createHindsightTools', () => {
});
describe('retain tool', () => {
it('should call client.retain with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
it('should call client.retain with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
@@ -56,21 +59,21 @@ describe('createHindsightTools', () => {
async: false,
});
const result = await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
});
const result = await tools.retain.execute({ content: 'Test content' });
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
documentId: undefined,
timestamp: undefined,
context: undefined,
tags: undefined,
metadata: undefined,
async: false,
});
expect(result).toEqual({ success: true, itemsCount: 5 });
});
it('should pass optional parameters to client.retain', async () => {
const tools = createHindsightTools({ client: mockClient });
it('should pass agent-provided optional inputs', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
@@ -79,7 +82,6 @@ describe('createHindsightTools', () => {
});
await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
@@ -90,100 +92,123 @@ describe('createHindsightTools', () => {
documentId: 'doc-123',
timestamp: '2024-01-01T00:00:00Z',
context: 'Test context',
tags: undefined,
metadata: undefined,
async: false,
});
});
it('should transform response correctly', async () => {
const tools = createHindsightTools({ client: mockClient });
it('should apply constructor-level retain options', async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
retain: {
async: true,
tags: ['env:prod', 'app:support'],
metadata: { version: '1.0' },
},
});
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 10,
async: false,
items_count: 1,
async: true,
});
const result = await tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
});
await tools.retain.execute({ content: 'Test content' });
expect(result).toEqual({ success: true, itemsCount: 10 });
expect(mockClient.retain).toHaveBeenCalledWith('test-bank', 'Test content', {
documentId: undefined,
timestamp: undefined,
context: undefined,
tags: ['env:prod', 'app:support'],
metadata: { version: '1.0' },
async: true,
});
});
});
describe('recall tool', () => {
it('should call client.recall with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
it('should call client.recall with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.recall).mockResolvedValue({
results: [
{
id: 'fact-1',
text: 'Test fact',
type: 'preference',
},
],
results: [{ id: 'fact-1', text: 'Test fact', type: 'preference' }],
});
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
});
const result = await tools.recall.execute({ query: 'Test query' });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
types: undefined,
maxTokens: undefined,
budget: undefined,
budget: 'mid',
queryTimestamp: undefined,
includeEntities: undefined,
includeChunks: undefined,
includeEntities: false,
includeChunks: false,
});
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: [],
});
it('should pass agent-provided queryTimestamp', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
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: undefined,
maxTokens: undefined,
budget: 'mid',
queryTimestamp: '2024-01-01T00:00:00Z',
includeEntities: false,
includeChunks: false,
});
});
it('should apply constructor-level recall options', async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
recall: {
types: ['preference', 'fact'],
maxTokens: 1000,
budget: 'high',
includeEntities: true,
includeChunks: true,
},
});
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
await tools.recall.execute({ query: 'Test query' });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test query', {
types: ['preference', 'fact'],
maxTokens: 1000,
budget: 'high',
queryTimestamp: '2024-01-01T00:00:00Z',
queryTimestamp: undefined,
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 tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.recall).mockResolvedValue({ results: undefined as any });
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
});
const result = await tools.recall.execute({ query: 'Test query' });
expect(result.results).toEqual([]);
});
it('should include entities when present', async () => {
const tools = createHindsightTools({ client: mockClient });
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
recall: { includeEntities: true },
});
const entities = {
'entity-1': {
entity_id: 'entity-1',
@@ -191,59 +216,39 @@ describe('createHindsightTools', () => {
observations: [{ text: 'Alice loves hiking' }],
},
};
vi.mocked(mockClient.recall).mockResolvedValue({ results: [], entities });
vi.mocked(mockClient.recall).mockResolvedValue({
results: [],
entities,
});
const result = await tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
includeEntities: true,
});
const result = await tools.recall.execute({ query: 'Test query' });
expect(result.entities).toEqual(entities);
});
});
describe('reflect tool', () => {
it('should call client.reflect with correct parameters', async () => {
const tools = createHindsightTools({ client: mockClient });
it('should call client.reflect with agent inputs and constructor defaults', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockResolvedValue({
text: 'Reflection result',
based_on: [
{
id: 'fact-1',
text: 'Supporting fact',
},
],
based_on: [{ id: 'fact-1', text: 'Supporting fact' }],
});
const result = await tools.reflect.execute({
bankId: 'test-bank',
query: 'What are my preferences?',
});
const result = await tools.reflect.execute({ query: 'What are my preferences?' });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'What are my preferences?', {
context: undefined,
budget: undefined,
budget: 'mid',
});
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',
});
it('should pass agent-provided context', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
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?', {
@@ -252,44 +257,43 @@ describe('createHindsightTools', () => {
});
});
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({
it('should apply constructor-level reflect budget', async () => {
const tools = createHindsightTools({
client: mockClient,
bankId: 'test-bank',
query: 'Test query',
reflect: { budget: 'low' },
});
vi.mocked(mockClient.reflect).mockResolvedValue({ text: 'Reflection result' });
await tools.reflect.execute({ query: 'Test query' });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'Test query', {
context: undefined,
budget: 'low',
});
});
it('should handle empty text response with fallback', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: undefined as any });
const result = await tools.reflect.execute({ 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 tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
const basedOn = [
{
id: 'fact-1',
text: 'User prefers spicy food',
type: 'preference',
},
{
id: 'fact-2',
text: 'User is allergic to nuts',
type: 'health',
},
{ 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?',
});
const result = await tools.reflect.execute({ query: 'What do I like?' });
expect(result.basedOn).toEqual(basedOn);
});
@@ -297,66 +301,70 @@ describe('createHindsightTools', () => {
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);
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.retain).mockRejectedValue(new Error('Retain failed'));
await expect(
tools.retain.execute({
bankId: 'test-bank',
content: 'Test content',
})
).rejects.toThrow('Retain failed');
await expect(tools.retain.execute({ 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);
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.recall).mockRejectedValue(new Error('Recall failed'));
await expect(
tools.recall.execute({
bankId: 'test-bank',
query: 'Test query',
})
).rejects.toThrow('Recall failed');
await expect(tools.recall.execute({ 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);
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockRejectedValue(new Error('Reflect failed'));
await expect(
tools.reflect.execute({
bankId: 'test-bank',
query: 'Test query',
})
).rejects.toThrow('Reflect failed');
await expect(tools.reflect.execute({ query: 'Test query' })).rejects.toThrow('Reflect failed');
});
});
describe('budget schema', () => {
it('should accept valid budget values', async () => {
const tools = createHindsightTools({ client: mockClient });
describe('budget defaults', () => {
it('should default recall budget to mid', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.recall).mockResolvedValue({ results: [] });
await tools.recall.execute({ query: 'Test' });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget: 'mid' }));
});
it('should default reflect budget to mid', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank' });
vi.mocked(mockClient.reflect).mockResolvedValue({ text: 'ok' });
await tools.reflect.execute({ query: 'Test' });
expect(mockClient.reflect).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget: 'mid' }));
});
it('should accept low/mid/high budget values', async () => {
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,
});
const tools = createHindsightTools({ client: mockClient, bankId: 'test-bank', recall: { budget } });
await tools.recall.execute({ query: 'Test' });
expect(mockClient.recall).toHaveBeenCalledWith('test-bank', 'Test', expect.objectContaining({ budget }));
}
});
});
describe('bankId enforcement', () => {
it('should always use the bankId from constructor options', async () => {
const tools = createHindsightTools({ client: mockClient, bankId: 'forced-bank' });
vi.mocked(mockClient.retain).mockResolvedValue({
success: true,
bank_id: 'forced-bank',
items_count: 1,
async: false,
});
await tools.retain.execute({ content: 'Test' });
expect(mockClient.retain).toHaveBeenCalledWith('forced-bank', 'Test', expect.anything());
});
});
});
+105 -219
View File
@@ -7,6 +7,12 @@ import { z } from 'zod';
export const BudgetSchema = z.enum(['low', 'mid', 'high']);
export type Budget = z.infer<typeof BudgetSchema>;
/**
* Fact types for filtering recall results.
*/
export const FactTypeSchema = z.enum(['world', 'experience', 'observation']);
export type FactType = z.infer<typeof FactTypeSchema>;
/**
* Recall result item from Hindsight
*/
@@ -105,15 +111,6 @@ export interface MentalModelResponse {
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
*/
@@ -128,36 +125,6 @@ export interface DocumentResponse {
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
*/
@@ -179,7 +146,7 @@ export interface HindsightClient {
bankId: string,
query: string,
options?: {
types?: string[];
types?: FactType[];
maxTokens?: number;
budget?: Budget;
trace?: boolean;
@@ -197,21 +164,10 @@ export interface HindsightClient {
options?: {
context?: string;
budget?: Budget;
maxTokens?: number;
}
): 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
@@ -221,148 +177,125 @@ export interface HindsightClient {
bankId: string,
documentId: string
): Promise<DocumentResponse | null>;
createDirective(
bankId: string,
options: {
name: string;
content: string;
priority?: number;
isActive?: boolean;
tags?: string[];
}
): Promise<CreateDirectiveResponse>;
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;
/** Memory bank ID to use for all tool calls (e.g. the user ID) */
bankId: string;
/** Options for the retain tool */
retain?: {
/** Fire-and-forget retain without waiting for completion (default: false) */
async?: boolean;
/** Tags always attached to every retained memory (default: undefined) */
tags?: string[];
/** Metadata always attached to every retained memory (default: undefined) */
metadata?: Record<string, string>;
/** Custom tool description */
description?: string;
};
/** Options for the recall tool */
recall?: {
/** Restrict results to these fact types: 'world', 'experience', 'observation' (default: undefined = all types) */
types?: FactType[];
/** Maximum tokens to return (default: undefined = API default) */
maxTokens?: number;
/** Processing budget controlling latency vs. depth (default: 'mid') */
budget?: Budget;
/** Include entity observations in results (default: false) */
includeEntities?: boolean;
/** Include raw source chunks in results (default: false) */
includeChunks?: boolean;
/** Custom tool description */
description?: string;
};
/** Options for the reflect tool */
reflect?: {
/** Processing budget controlling latency vs. depth (default: 'mid') */
budget?: Budget;
/** Maximum tokens for the response (default: undefined = API default) */
maxTokens?: number;
/** Custom tool description */
description?: string;
};
/** Options for the getMentalModel tool */
getMentalModel?: {
/** Custom tool description */
description?: string;
};
/** Options for the getDocument tool */
getDocument?: {
/** Custom tool description */
description?: 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
* The bank ID and all infrastructure concerns (budget, tags, async mode, etc.)
* are fixed at creation time. The agent only controls semantic inputs:
* content, queries, names, and timestamps.
*
* @example
* ```ts
* const tools = createHindsightTools({
* client: hindsightClient,
* bankId: userId,
* recall: { budget: 'high', includeEntities: true },
* retain: { async: true, tags: ['env:prod'] },
* });
*
* // Use with AI SDK
* const result = await generateText({
* model: openai('gpt-4'),
* model: openai('gpt-4o'),
* tools,
* prompt: 'Remember that Alice loves hiking',
* messages,
* });
* ```
*/
export function createHindsightTools({
client,
retainDescription,
recallDescription,
reflectDescription,
createMentalModelDescription,
queryMentalModelDescription,
getDocumentDescription,
bankId,
retain: retainOpts = {},
recall: recallOpts = {},
reflect: reflectOpts = {},
getMentalModel: getMentalModelOpts = {},
getDocument: getDocumentOpts = {},
}: HindsightToolsOptions) {
// Agent-controlled params only: content, timestamp, documentId, context
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'),
});
// Agent-controlled params only: query, queryTimestamp
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'),
});
// Agent-controlled params only: query, context
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 getMentalModelParams = z.object({
mentalModelId: z.string().describe('ID of the mental model to retrieve'),
});
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'),
});
type RetainInput = z.infer<typeof retainParams>;
type RetainOutput = { success: boolean; itemsCount: number };
@@ -372,37 +305,31 @@ export function createHindsightTools({
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 GetMentalModelInput = z.infer<typeof getMentalModelParams>;
type GetMentalModelOutput = { 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 };
return {
retain: tool<RetainInput, RetainOutput>({
description:
retainDescription ??
retainOpts.description ??
`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,
bankId,
documentId: input.documentId,
tags: input.tags,
hasContent: !!input.content,
});
const result = await client.retain(input.bankId, input.content, {
const result = await client.retain(bankId, input.content, {
documentId: input.documentId,
timestamp: input.timestamp,
context: input.context,
tags: input.tags,
metadata: input.metadata as Record<string, string> | undefined,
tags: retainOpts.tags,
metadata: retainOpts.metadata,
async: retainOpts.async ?? false,
});
return { success: result.success, itemsCount: result.items_count };
},
@@ -410,17 +337,17 @@ export function createHindsightTools({
recall: tool<RecallInput, RecallOutput>({
description:
recallDescription ??
recallOpts.description ??
`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,
const result = await client.recall(bankId, input.query, {
types: recallOpts.types,
maxTokens: recallOpts.maxTokens,
budget: recallOpts.budget ?? 'mid',
queryTimestamp: input.queryTimestamp,
includeEntities: input.includeEntities,
includeChunks: input.includeChunks,
includeEntities: recallOpts.includeEntities ?? false,
includeChunks: recallOpts.includeChunks ?? false,
});
return {
results: result.results ?? [],
@@ -431,13 +358,14 @@ export function createHindsightTools({
reflect: tool<ReflectInput, ReflectOutput>({
description:
reflectDescription ??
reflectOpts.description ??
`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, {
const result = await client.reflect(bankId, input.query, {
context: input.context,
budget: input.budget,
budget: reflectOpts.budget ?? 'mid',
maxTokens: reflectOpts.maxTokens,
});
return {
text: result.text ?? 'No insights available yet.',
@@ -446,34 +374,13 @@ export function createHindsightTools({
},
}),
createMentalModel: tool<CreateMentalModelInput, CreateMentalModelOutput>({
getMentalModel: tool<GetMentalModelInput, GetMentalModelOutput>({
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,
getMentalModelOpts.description ??
`Retrieve a mental model to get consolidated knowledge synthesized from memories. Mental models provide synthesized insights that are faster and more efficient to retrieve than searching through raw memories.`,
inputSchema: getMentalModelParams,
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);
const result = await client.getMentalModel(bankId, input.mentalModelId);
return {
content: result.content ?? 'No content available yet.',
name: result.name,
@@ -484,11 +391,11 @@ export function createHindsightTools({
getDocument: tool<GetDocumentInput, GetDocumentOutput>({
description:
getDocumentDescription ??
getDocumentOpts.description ??
`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);
const result = await client.getDocument(bankId, input.documentId);
if (!result) {
return null;
}
@@ -501,27 +408,6 @@ export function createHindsightTools({
},
}),
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,
};
},
}),
};
}
+196
View File
@@ -0,0 +1,196 @@
# Using hindsight-openclaw with NemoClaw
This guide covers running the `hindsight-openclaw` plugin inside a [NemoClaw](https://nemoclaw.ai) sandbox. NemoClaw runs OpenClaw inside an OpenShell sandbox, so the plugin's outbound calls to `api.hindsight.vectorize.io` must be explicitly allowed in the sandbox's network egress policy.
## Prerequisites
- NemoClaw installed and a sandbox created (`nemoclaw onboard`)
- OpenClaw installed (`brew install openclaw` or equivalent)
- A Hindsight API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
- The plugin source built (`npm run build` in this directory)
## Step 1: Create a Hindsight memory bank
Create the bank the plugin will write to. The bank ID follows the pattern `{bankIdPrefix}-openclaw` when `dynamicBankId` is false:
```bash
curl -X PUT "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"mission": "Memory bank for my NemoClaw sandbox."}'
```
## Step 2: Install the plugin
Install the plugin as a copy (not a symlink) so the OpenClaw LaunchAgent can access it:
```bash
# Build first if you haven't already
npm run build
# Install (copy, not link — required for LaunchAgent access)
openclaw plugins install /path/to/hindsight-integrations/openclaw
```
Alternatively, install from npm:
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
## Step 3: Configure the plugin
Add the plugin config to `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-hindsight-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
**Config notes:**
| Field | Value | Why |
|-------|-------|-----|
| `hindsightApiUrl` + `hindsightApiToken` | External API URL + key | Skips the local daemon; no `uvx`/`uv` required inside the sandbox |
| `llmProvider: "claude-code"` | `"claude-code"` | Satisfies LLM detection without a separate API key — Claude Code is available in the sandbox via the `claude_code` policy |
| `dynamicBankId: false` | `false` | All conversations write to one bank; easier to verify during testing |
| `bankIdPrefix` | e.g. `"my-sandbox"` | Results in bank ID `my-sandbox-openclaw` |
> **Note:** The gateway log will say `Dynamic bank IDs disabled - using static bank: openclaw` — this is a misleading log message. The actual bank ID used at runtime correctly applies the prefix (e.g. `my-sandbox-openclaw`). You can verify by watching for `[Hindsight] Default bank: my-sandbox-openclaw` in the logs after full initialization.
## Step 4: Add the Hindsight network policy to the sandbox
The sandbox blocks all outbound traffic by default. You need to add `api.hindsight.vectorize.io` to the egress policy.
Get the current full policy by running `openshell sandbox get <name>` and save it to a YAML file, then add the `hindsight` block under `network_policies`:
```yaml
network_policies:
# ... your existing policies ...
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
Apply it:
```bash
openshell policy set <sandbox-name> --policy /path/to/full-policy.yaml --wait
```
> **Important:** `openshell policy set` replaces the entire policy, not just patches it. Make sure your YAML includes all existing network policies or they will be removed.
Verify the policy loaded:
```bash
openshell policy get <sandbox-name>
# Should show: Status: Loaded, and version incremented
```
## Step 5: Restart the OpenClaw gateway
```bash
openclaw gateway restart
```
Watch the logs to confirm the plugin loaded and the API is reachable:
```bash
# Should see:
# [Hindsight] Plugin loaded successfully
# [Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
# [Hindsight] External API health: {"status":"healthy","database":"connected"}
# [Hindsight] Default bank: my-sandbox-openclaw
# [Hindsight] ✓ Ready (external API mode)
grep Hindsight ~/.openclaw/logs/gateway.log | tail -20
```
## Step 6: Test
Send a message to the agent:
```bash
openclaw agent --agent main --session-id test-1 \
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
```
Verify memory was retained (check logs):
```bash
grep "Retained\|agent_end" ~/.openclaw/logs/gateway.log | tail -5
# Should see: [Hindsight] Retained N messages to bank my-sandbox-openclaw for session ...
```
Test recall in a new session:
```bash
openclaw agent --agent main --session-id test-2 \
-m "What do you remember about me?"
# Should recall your name and preferences from the previous session
```
You can also verify directly against the API:
```bash
curl -s -X POST "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw/memories/recall" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"query": "what do you know about the user", "max_tokens": 512}'
```
## Troubleshooting
**Plugin fails to load with `EPERM: operation not permitted, scandir`**
You used `--link` when installing. The OpenClaw LaunchAgent runs under a restricted macOS security context and cannot access `~/Documents` or other user directories by symlink. Reinstall without `--link`:
```bash
openclaw plugins uninstall hindsight-openclaw
openclaw plugins install /path/to/hindsight-integrations/openclaw # no --link
```
**`[Hindsight] Failed to retain memory (HTTP 403)`**
The sandbox network policy is blocking the outbound call. Check that:
1. The `hindsight` network policy block is present in your policy YAML
2. The policy was applied and shows `Status: Loaded` (`openshell policy get <name>`)
3. The `binaries` list includes `/usr/local/bin/openclaw`
**Gateway restart times out but then recovers**
This is normal on first restart after installing a plugin — the LaunchAgent takes a moment to reload. The gateway is healthy if `openclaw gateway status` shows `RPC probe: ok`.
**`openclaw agent` fails with `Pass --to, --session-id, or --agent`**
You need to specify a session. Use `--agent main` to use the default agent, or `--session-id <any-string>` to create a named session.
+60
View File
@@ -0,0 +1,60 @@
# hindsight-nemoclaw
One-command setup for [Hindsight](https://hindsight.vectorize.io) persistent memory on [NemoClaw](https://nemoclaw.ai) sandboxes.
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with strict network egress policies. This package automates the full setup: installing the `hindsight-openclaw` plugin, configuring external API mode, merging the Hindsight egress rule into your sandbox policy, and restarting the gateway.
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
## Documentation
Full setup guide, pitfalls, and troubleshooting:
**[NemoClaw Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/nemoclaw)**
Or see [NEMOCLAW.md](./NEMOCLAW.md) in this directory for a step-by-step walkthrough.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
## What It Does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads current sandbox policy, merges Hindsight egress rule, re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
## Links
- [Hindsight Documentation](https://vectorize.io/hindsight)
- [NemoClaw](https://nemoclaw.ai)
- [OpenClaw](https://openclaw.ai)
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
## License
MIT
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
{
"name": "@vectorize-io/hindsight-nemoclaw",
"version": "0.1.0",
"description": "Setup CLI for hindsight-openclaw on NemoClaw sandboxes — installs the plugin, configures external API mode, and applies the OpenShell network policy",
"type": "module",
"main": "dist/cli.js",
"bin": {
"hindsight-nemoclaw": "dist/cli.js"
},
"keywords": [
"nemoclaw",
"openclaw",
"memory",
"ai",
"agent",
"hindsight",
"openshell",
"nvidia"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/nemoclaw"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc && node -e \"const f='dist/cli.js',s=require('fs');s.writeFileSync(f,'#!/usr/bin/env node\\n'+s.readFileSync(f,'utf8'));s.chmodSync(f,0o755)\"",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,84 @@
import { runSetup } from './setup.js';
import type { CliArgs } from './types.js';
function usage(): void {
process.stdout.write(`
hindsight-nemoclaw — Setup CLI for Hindsight memory on NemoClaw sandboxes
Usage:
hindsight-nemoclaw setup [options]
Required options:
--sandbox <name> NemoClaw sandbox name (e.g. my-assistant)
--api-url <url> Hindsight Cloud API URL (https://api.hindsight.vectorize.io)
--api-token <token> Hindsight API key from https://ui.hindsight.vectorize.io
--bank-prefix <prefix> Bank ID prefix (memories go to <prefix>-openclaw)
Optional options:
--skip-policy Skip the openshell policy update
--skip-plugin-install Skip openclaw plugins install
--dry-run Print what would be changed without executing
--help Show this help
Example:
hindsight-nemoclaw setup \\
--sandbox my-assistant \\
--api-url https://api.hindsight.vectorize.io \\
--api-token hsk_abc123 \\
--bank-prefix my-sandbox
`);
}
function parseArgs(argv: string[]): CliArgs | null {
const args = argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
usage();
return null;
}
if (args[0] !== 'setup') {
process.stderr.write(`Unknown command: ${args[0]}\nRun with --help for usage.\n`);
process.exit(1);
}
const get = (flag: string): string | undefined => {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
};
const sandbox = get('--sandbox');
const apiUrl = get('--api-url');
const apiToken = get('--api-token');
const bankPrefix = get('--bank-prefix');
const missing: string[] = [];
if (!sandbox) missing.push('--sandbox');
if (!apiUrl) missing.push('--api-url');
if (!apiToken) missing.push('--api-token');
if (!bankPrefix) missing.push('--bank-prefix');
if (missing.length > 0) {
process.stderr.write(`Missing required options: ${missing.join(', ')}\nRun with --help for usage.\n`);
process.exit(1);
}
return {
sandbox: sandbox!,
apiUrl: apiUrl!,
apiToken: apiToken!,
bankPrefix: bankPrefix!,
skipPolicy: args.includes('--skip-policy'),
skipPluginInstall: args.includes('--skip-plugin-install'),
dryRun: args.includes('--dry-run'),
};
}
const args = parseArgs(process.argv);
if (args) {
runSetup(args).catch(err => {
process.stderr.write(`\nError: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
}
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { mergePluginConfig } from './openclaw-config.js';
import type { OpenClawConfig, HindsightPluginConfig } from './openclaw-config.js';
const PLUGIN_CONFIG: HindsightPluginConfig = {
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
hindsightApiToken: 'hsk_test123',
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: 'my-sandbox',
};
const BASE_CONFIG: OpenClawConfig = {
meta: { lastTouchedVersion: '2026.3.2' },
gateway: { port: 18789, mode: 'local' },
agents: {
defaults: { model: { primary: 'openai/gpt-5' } },
},
plugins: {
slots: { memory: 'memory-core' },
entries: {
'memory-core': { enabled: false },
},
},
};
describe('mergePluginConfig', () => {
it('sets hindsight-openclaw as the memory slot', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
});
it('enables the hindsight-openclaw entry', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('writes the full plugin config', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(config?.hindsightApiToken).toBe('hsk_test123');
expect(config?.llmProvider).toBe('claude-code');
expect(config?.dynamicBankId).toBe(false);
expect(config?.bankIdPrefix).toBe('my-sandbox');
});
it('preserves existing top-level config fields', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.gateway).toEqual({ port: 18789, mode: 'local' });
expect(result.agents).toBeDefined();
});
it('preserves existing plugin entries', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['memory-core']?.enabled).toBe(false);
});
it('merges into existing hindsight-openclaw entry without overwriting other fields', () => {
const configWithExisting: OpenClawConfig = {
...BASE_CONFIG,
plugins: {
...BASE_CONFIG.plugins,
entries: {
...BASE_CONFIG.plugins?.entries,
'hindsight-openclaw': {
enabled: true,
config: { embedPackagePath: '/some/local/path' },
},
},
},
};
const result = mergePluginConfig(configWithExisting, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
// New fields written
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
// Existing custom field preserved
expect(config?.embedPackagePath).toBe('/some/local/path');
});
it('handles missing plugins section gracefully', () => {
const minimal: OpenClawConfig = { gateway: { port: 18789 } };
const result = mergePluginConfig(minimal, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('does not mutate the original config', () => {
const original = JSON.parse(JSON.stringify(BASE_CONFIG)) as OpenClawConfig;
mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(JSON.stringify(BASE_CONFIG)).toBe(JSON.stringify(original));
});
it('records install metadata', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const install = result.plugins?.installs?.['hindsight-openclaw'] as Record<string, unknown>;
expect(install?.source).toBe('npm');
expect(install?.version).toBe('latest');
expect(typeof install?.installedAt).toBe('string');
});
});
@@ -0,0 +1,106 @@
import { readFile, writeFile, rename } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
const CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
export interface HindsightPluginConfig {
hindsightApiUrl: string;
hindsightApiToken: string;
llmProvider: string;
dynamicBankId: boolean;
bankIdPrefix: string;
}
export interface OpenClawConfig {
plugins?: {
slots?: Record<string, string>;
entries?: Record<string, { enabled: boolean; config?: Record<string, unknown> }>;
installs?: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export async function readOpenClawConfig(configPath = CONFIG_PATH): Promise<OpenClawConfig> {
let raw: string;
try {
raw = await readFile(configPath, 'utf8');
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new Error(
`OpenClaw config not found at ${configPath}.\n` +
`Run \`openclaw\` once to initialize it, then re-run setup.`
);
}
throw err;
}
return JSON.parse(raw) as OpenClawConfig;
}
export function mergePluginConfig(
config: OpenClawConfig,
pluginConfig: HindsightPluginConfig
): OpenClawConfig {
const plugins = config.plugins ?? {};
const entries = plugins.entries ?? {};
const existing = entries['hindsight-openclaw'] ?? { enabled: true };
return {
...config,
plugins: {
...plugins,
slots: {
...(plugins.slots ?? {}),
memory: 'hindsight-openclaw',
},
entries: {
...entries,
'hindsight-openclaw': {
...existing,
enabled: true,
config: {
...(existing.config ?? {}),
hindsightApiUrl: pluginConfig.hindsightApiUrl,
hindsightApiToken: pluginConfig.hindsightApiToken,
llmProvider: pluginConfig.llmProvider,
dynamicBankId: pluginConfig.dynamicBankId,
bankIdPrefix: pluginConfig.bankIdPrefix,
},
},
},
installs: {
...(plugins.installs ?? {}),
'hindsight-openclaw': {
source: 'npm',
version: 'latest',
installedAt: new Date().toISOString(),
},
},
},
};
}
export async function writeOpenClawConfig(
config: OpenClawConfig,
configPath = CONFIG_PATH
): Promise<void> {
const contents = JSON.stringify(config, null, 2) + '\n';
const tmp = `${configPath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, contents, 'utf8');
await rename(tmp, configPath);
}
export async function applyPluginConfig(
pluginConfig: HindsightPluginConfig,
configPath = CONFIG_PATH
): Promise<void> {
const current = await readOpenClawConfig(configPath);
const updated = mergePluginConfig(current, pluginConfig);
await writeOpenClawConfig(updated, configPath);
}
export { CONFIG_PATH };
export { dirname };
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { stripAnsi, extractPolicyYaml, parseSandboxPolicy } from './policy-reader.js';
import { serializePolicy } from './policy-writer.js';
// Fixture: actual output of `openshell sandbox get my-assistant`
// (ANSI codes represented as escape sequences)
const FIXTURE_RAW = `\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m
\x1b[2mId:\x1b[0m 61c993f1-010f-4eca-a1ac-d6ddec9d604a
\x1b[2mName:\x1b[0m my-assistant
\x1b[2mNamespace:\x1b[0m openshell
\x1b[2mPhase:\x1b[0m Ready
\x1b[1m\x1b[36mPolicy:\x1b[39m\x1b[0m
\x1b[2mversion\x1b[0m\x1b[2m:\x1b[0m 1
\x1b[2mfilesystem_policy\x1b[0m\x1b[2m:\x1b[0m
\x1b[2minclude_workdir\x1b[0m\x1b[2m:\x1b[0m true
\x1b[2mread_only\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/usr
\x1b[2m- \x1b[0m/lib
\x1b[2mread_write\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/sandbox
\x1b[2m- \x1b[0m/tmp
\x1b[2mnetwork_policies\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mclaude_code\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mname\x1b[0m\x1b[2m:\x1b[0m claude_code
\x1b[2mendpoints\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mhost: api.anthropic.com
\x1b[2mport\x1b[0m\x1b[2m:\x1b[0m 443
\x1b[2mrules\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mallow:
\x1b[2mmethod\x1b[0m\x1b[2m:\x1b[0m '*'
\x1b[2mpath\x1b[0m\x1b[2m:\x1b[0m /**
\x1b[2mbinaries\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mpath: /usr/local/bin/claude
`;
describe('stripAnsi', () => {
it('removes ANSI escape codes', () => {
expect(stripAnsi('\x1b[1m\x1b[36mHello\x1b[39m\x1b[0m')).toBe('Hello');
});
it('leaves plain strings unchanged', () => {
expect(stripAnsi('version: 1')).toBe('version: 1');
});
it('handles strings with no ANSI codes', () => {
expect(stripAnsi(' - /usr')).toBe(' - /usr');
});
});
describe('extractPolicyYaml', () => {
it('extracts the Policy: section and dedents by 2 spaces', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).toContain('version: 1');
expect(result).toContain('filesystem_policy:');
expect(result).toContain('network_policies:');
});
it('does not include the Sandbox: section', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).not.toContain('Sandbox:');
expect(result).not.toContain('my-assistant');
});
it('throws if Policy: section is missing', () => {
expect(() => extractPolicyYaml('no policy here')).toThrow('Could not find "Policy:"');
});
});
describe('parseSandboxPolicy', () => {
it('parses version field', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.version).toBe(1);
});
it('parses filesystem_policy', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.filesystem_policy?.include_workdir).toBe(true);
expect(policy.filesystem_policy?.read_only).toContain('/usr');
});
it('parses network_policies', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.network_policies).toBeDefined();
expect(policy.network_policies?.claude_code).toBeDefined();
expect(policy.network_policies?.claude_code?.name).toBe('claude_code');
});
it('is idempotent — parse → serialize → parse yields same structure', () => {
const policy1 = parseSandboxPolicy(FIXTURE_RAW);
const yamlStr = serializePolicy(policy1);
// Re-wrap in a Policy: header to match the expected format
const wrapped = 'Policy:\n' + yamlStr.split('\n').map((l: string) => ` ${l}`).join('\n');
const policy2 = parseSandboxPolicy(wrapped);
expect(policy2.version).toBe(policy1.version);
expect(Object.keys(policy2.network_policies ?? {})).toEqual(
Object.keys(policy1.network_policies ?? {})
);
});
});
@@ -0,0 +1,88 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
const execFileAsync = promisify(execFile);
/** Strip ANSI escape codes from a string */
export function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*m/g, '');
}
/**
* Extract and dedent the policy section from `openshell sandbox get` output.
* The output looks like:
*
* Sandbox:
* Id: ...
* Name: ...
*
* Policy:
* version: 1
* filesystem_policy:
* ...
*
* We need to extract everything after `Policy:` and dedent by 2 spaces.
*/
export function extractPolicyYaml(raw: string): string {
const stripped = stripAnsi(raw);
const lines = stripped.split('\n');
const policyHeaderIdx = lines.findIndex(l => l.trimEnd() === 'Policy:');
if (policyHeaderIdx === -1) {
throw new Error('Could not find "Policy:" section in `openshell sandbox get` output');
}
const policyLines = lines.slice(policyHeaderIdx + 1);
// Dedent by 2 spaces (the policy block is indented under `Policy:`)
const dedented = policyLines.map(l => {
if (l.startsWith(' ')) return l.slice(2);
return l;
});
// Drop trailing empty lines
while (dedented.length > 0 && dedented[dedented.length - 1].trim() === '') {
dedented.pop();
}
return dedented.join('\n');
}
/**
* Parse `openshell sandbox get <name>` output into a SandboxPolicy object.
* Throws a descriptive error if parsing fails.
*/
export function parseSandboxPolicy(rawOutput: string): SandboxPolicy {
const policyYaml = extractPolicyYaml(rawOutput);
try {
const parsed = yaml.load(policyYaml);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Parsed policy is not an object');
}
return parsed as SandboxPolicy;
} catch (err) {
throw new Error(
`Failed to parse sandbox policy YAML.\n` +
`This may mean the openshell output format has changed.\n` +
`Apply the Hindsight policy manually using the instructions in NEMOCLAW.md.\n` +
`Parse error: ${err}`
);
}
}
/** Run `openshell sandbox get <sandbox>` and return parsed policy */
export async function readSandboxPolicy(sandboxName: string): Promise<SandboxPolicy> {
let stdout: string;
try {
const result = await execFileAsync('openshell', ['sandbox', 'get', sandboxName]);
stdout = result.stdout;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to run \`openshell sandbox get ${sandboxName}\`: ${msg}`);
}
return parseSandboxPolicy(stdout);
}
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { parseSandboxPolicy } from './policy-reader.js';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const BASE_POLICY: SandboxPolicy = {
version: 1,
filesystem_policy: {
include_workdir: true,
read_only: ['/usr', '/lib'],
read_write: ['/sandbox', '/tmp'],
},
network_policies: {
claude_code: {
name: 'claude_code',
endpoints: [
{
host: 'api.anthropic.com',
port: 443,
rules: [{ allow: { method: '*', path: '/**' } }],
},
],
binaries: [{ path: '/usr/local/bin/claude' }],
},
},
};
describe('hasHindsightPolicy', () => {
it('returns false when no hindsight policy exists', () => {
expect(hasHindsightPolicy(BASE_POLICY)).toBe(false);
});
it('returns false when network_policies is undefined', () => {
expect(hasHindsightPolicy({ version: 1 })).toBe(false);
});
it('returns true when hindsight policy is present', () => {
const withHindsight = mergeHindsightPolicy(BASE_POLICY);
expect(hasHindsightPolicy(withHindsight)).toBe(true);
});
});
describe('mergeHindsightPolicy', () => {
it('adds the hindsight network policy block', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.hindsight).toBeDefined();
expect(result.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
});
it('preserves all existing network policies', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.claude_code).toBeDefined();
expect(result.network_policies?.claude_code?.name).toBe('claude_code');
});
it('sets the correct binary path', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const binaries = result.network_policies?.hindsight?.binaries ?? [];
expect(binaries.some(b => b.path === OPENCLAW_BINARY)).toBe(true);
});
it('includes GET, POST, and PUT rules', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const rules = result.network_policies?.hindsight?.endpoints[0].rules ?? [];
const methods = rules.map(r => r.allow.method);
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
});
it('is idempotent — merging twice yields the same result', () => {
const once = mergeHindsightPolicy(BASE_POLICY);
const twice = mergeHindsightPolicy(once);
expect(JSON.stringify(twice.network_policies?.hindsight)).toBe(
JSON.stringify(once.network_policies?.hindsight)
);
});
it('does not mutate the original policy', () => {
const original = JSON.parse(JSON.stringify(BASE_POLICY)) as SandboxPolicy;
mergeHindsightPolicy(BASE_POLICY);
expect(BASE_POLICY.network_policies?.hindsight).toBeUndefined();
expect(JSON.stringify(BASE_POLICY)).toBe(JSON.stringify(original));
});
});
describe('serializePolicy', () => {
it('produces valid YAML that round-trips through parseSandboxPolicy', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yamlStr = serializePolicy(merged);
// Wrap in Policy: header as parseSandboxPolicy expects
const wrapped = 'Policy:\n' + yamlStr.split('\n').map(l => ` ${l}`).join('\n');
const reparsed = parseSandboxPolicy(wrapped);
expect(reparsed.version).toBe(merged.version);
expect(reparsed.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
expect(reparsed.network_policies?.claude_code).toBeDefined();
});
it('includes all network policies in output', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yaml = serializePolicy(merged);
expect(yaml).toContain('claude_code:');
expect(yaml).toContain('hindsight:');
expect(yaml).toContain(HINDSIGHT_HOST);
});
});
@@ -0,0 +1,59 @@
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_POLICY_NAME, HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const HINDSIGHT_NETWORK_POLICY = {
name: HINDSIGHT_POLICY_NAME,
endpoints: [
{
host: HINDSIGHT_HOST,
port: 443,
protocol: 'rest',
tls: 'terminate',
enforcement: 'enforce',
rules: [
{ allow: { method: 'GET', path: '/**' } },
{ allow: { method: 'POST', path: '/**' } },
{ allow: { method: 'PUT', path: '/**' } },
],
},
],
binaries: [{ path: OPENCLAW_BINARY }],
};
/**
* Returns true if the policy already has a correct Hindsight network policy entry.
*/
export function hasHindsightPolicy(policy: SandboxPolicy): boolean {
const np = policy.network_policies?.[HINDSIGHT_POLICY_NAME];
if (!np) return false;
return np.endpoints?.some(e => e.host === HINDSIGHT_HOST) ?? false;
}
/**
* Merge the Hindsight network policy block into a SandboxPolicy.
* Idempotent — if the block already exists and is correct, returns policy unchanged.
*/
export function mergeHindsightPolicy(policy: SandboxPolicy): SandboxPolicy {
const updated: SandboxPolicy = {
...policy,
network_policies: {
...(policy.network_policies ?? {}),
[HINDSIGHT_POLICY_NAME]: HINDSIGHT_NETWORK_POLICY,
},
};
return updated;
}
/**
* Serialize a SandboxPolicy to a YAML string suitable for `openshell policy set`.
*/
export function serializePolicy(policy: SandboxPolicy): string {
return yaml.dump(policy, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
}
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CliArgs } from './types.js';
// Mock all external I/O before importing setup
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
vi.mock('./policy-reader.js', () => ({
readSandboxPolicy: vi.fn(),
}));
vi.mock('./policy-writer.js', () => ({
hasHindsightPolicy: vi.fn(),
mergeHindsightPolicy: vi.fn(),
serializePolicy: vi.fn(),
}));
vi.mock('./openclaw-config.js', () => ({
applyPluginConfig: vi.fn(),
}));
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
rm: vi.fn(),
}));
const BASE_ARGS: CliArgs = {
sandbox: 'my-assistant',
apiUrl: 'https://api.hindsight.vectorize.io',
apiToken: 'hsk_test123',
bankPrefix: 'my-sandbox',
skipPolicy: false,
skipPluginInstall: false,
dryRun: false,
};
describe('runSetup', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { execFile } = await import('child_process');
const execFileMock = vi.mocked(execFile);
// Default: all shell commands succeed
execFileMock.mockImplementation((_cmd, _args, callback?: unknown) => {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { readSandboxPolicy } = await import('./policy-reader.js');
vi.mocked(readSandboxPolicy).mockResolvedValue({
version: 1,
network_policies: { claude_code: { name: 'claude_code', endpoints: [] } },
});
const { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(false);
vi.mocked(mergeHindsightPolicy).mockImplementation(p => ({ ...p, network_policies: { ...p.network_policies, hindsight: { name: 'hindsight', endpoints: [] } } }));
vi.mocked(serializePolicy).mockReturnValue('version: 1\n');
const { applyPluginConfig } = await import('./openclaw-config.js');
vi.mocked(applyPluginConfig).mockResolvedValue(undefined);
});
it('runs all steps in order for a clean install', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('which openshell'))).toBe(true);
expect(calls.some(c => c.includes('which openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openclaw plugins install @vectorize-io/hindsight-openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openshell policy set my-assistant'))).toBe(true);
expect(calls.some(c => c.includes('openclaw gateway restart'))).toBe(true);
});
it('skips plugin install when --skip-plugin-install is set', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, skipPluginInstall: true });
expect(calls.some(c => c.includes('plugins install'))).toBe(false);
});
it('skips policy update when --skip-policy is set', async () => {
const { runSetup } = await import('./setup.js');
const { readSandboxPolicy } = await import('./policy-reader.js');
await runSetup({ ...BASE_ARGS, skipPolicy: true });
expect(vi.mocked(readSandboxPolicy)).not.toHaveBeenCalled();
});
it('skips policy set when Hindsight policy already exists', async () => {
const { hasHindsightPolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(true);
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('openshell policy set'))).toBe(false);
});
it('does not execute any shell commands in dry-run mode', async () => {
const { execFile } = await import('child_process');
const { applyPluginConfig } = await import('./openclaw-config.js');
const { writeFile } = await import('fs/promises');
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, dryRun: true });
// which checks still run (preflight), but no actual commands
const execCalls = vi.mocked(execFile).mock.calls.map(c => `${c[0]} ${(c[1] as string[]).join(' ')}`);
expect(execCalls.some(c => c.includes('plugins install'))).toBe(false);
expect(execCalls.some(c => c.includes('policy set'))).toBe(false);
expect(execCalls.some(c => c.includes('gateway restart'))).toBe(false);
expect(vi.mocked(applyPluginConfig)).not.toHaveBeenCalled();
expect(vi.mocked(writeFile)).not.toHaveBeenCalled();
});
it('fails early if openshell is not on PATH', async () => {
const { execFile } = await import('child_process');
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
if (cmd === 'which' && (args as string[])[0] === 'openshell') {
if (typeof callback === 'function') {
(callback as (err: Error) => void)(new Error('not found'));
}
} else {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await expect(runSetup(BASE_ARGS)).rejects.toThrow('openshell');
});
});
@@ -0,0 +1,148 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomBytes } from 'crypto';
import type { CliArgs } from './types.js';
import { readSandboxPolicy } from './policy-reader.js';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { applyPluginConfig } from './openclaw-config.js';
const execFileAsync = promisify(execFile);
function log(msg: string) {
process.stdout.write(`${msg}\n`);
}
function step(n: number, msg: string) {
log(`\n[${n}] ${msg}`);
}
async function which(bin: string): Promise<boolean> {
try {
await execFileAsync('which', [bin]);
return true;
} catch {
return false;
}
}
export async function runSetup(args: CliArgs): Promise<void> {
log('\nhindsight-nemoclaw setup');
log('─'.repeat(40));
// Step 0 — Preflight
step(0, 'Preflight checks...');
const [hasOpenshell, hasOpenclaw] = await Promise.all([which('openshell'), which('openclaw')]);
if (!hasOpenshell) {
throw new Error('`openshell` not found on PATH. Install it from https://openshell.ai');
}
if (!hasOpenclaw) {
throw new Error('`openclaw` not found on PATH. Install it from https://openclaw.ai');
}
log(' ✓ openshell found');
log(' ✓ openclaw found');
// Step 1 — Install hindsight-openclaw plugin
if (!args.skipPluginInstall) {
step(1, 'Installing @vectorize-io/hindsight-openclaw plugin...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw plugins install @vectorize-io/hindsight-openclaw');
} else {
const { stdout } = await execFileAsync('openclaw', [
'plugins', 'install', '@vectorize-io/hindsight-openclaw',
]);
log(stdout.trim() || ' ✓ Plugin installed');
}
} else {
step(1, 'Skipping plugin install (--skip-plugin-install)');
}
// Step 2 — Configure ~/.openclaw/openclaw.json
step(2, 'Configuring plugin in ~/.openclaw/openclaw.json...');
const pluginConfig = {
hindsightApiUrl: args.apiUrl,
hindsightApiToken: args.apiToken,
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: args.bankPrefix,
};
if (args.dryRun) {
log(` [dry-run] would write plugin config to ~/.openclaw/openclaw.json`);
log(` config: ${JSON.stringify(pluginConfig, null, 4).split('\n').join('\n ')}`);
} else {
await applyPluginConfig(pluginConfig);
log(` ✓ Plugin config written (bank: ${args.bankPrefix}-openclaw)`);
}
// Step 3 — Apply OpenShell network policy
if (!args.skipPolicy) {
step(3, `Applying Hindsight network policy to sandbox "${args.sandbox}"...`);
const currentPolicy = await readSandboxPolicy(args.sandbox);
if (hasHindsightPolicy(currentPolicy)) {
log(' ✓ Hindsight policy already present — skipping');
} else {
const updatedPolicy = mergeHindsightPolicy(currentPolicy);
const policyYaml = serializePolicy(updatedPolicy);
if (args.dryRun) {
log(' [dry-run] would apply policy:');
log(policyYaml.split('\n').map(l => ` ${l}`).join('\n'));
} else {
const tmpFile = join(tmpdir(), `hindsight-policy-${randomBytes(6).toString('hex')}.yaml`);
try {
await writeFile(tmpFile, policyYaml, 'utf8');
const { stdout } = await execFileAsync('openshell', [
'policy', 'set', args.sandbox, '--policy', tmpFile, '--wait',
]);
log(stdout.trim() || ` ✓ Policy applied to sandbox "${args.sandbox}"`);
} finally {
await rm(tmpFile, { force: true });
}
}
}
} else {
step(3, 'Skipping policy update (--skip-policy)');
log(' Add the following block to your sandbox network_policies manually:');
log('');
log(' hindsight:');
log(' name: hindsight');
log(' endpoints:');
log(' - host: api.hindsight.vectorize.io');
log(' port: 443');
log(' protocol: rest');
log(' tls: terminate');
log(' enforcement: enforce');
log(' rules:');
log(' - allow: { method: GET, path: /** }');
log(' - allow: { method: POST, path: /** }');
log(' - allow: { method: PUT, path: /** }');
log(' binaries:');
log(' - path: /usr/local/bin/openclaw');
}
// Step 4 — Restart gateway
step(4, 'Restarting OpenClaw gateway...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw gateway restart');
} else {
await execFileAsync('openclaw', ['gateway', 'restart']);
log(' ✓ Gateway restarted');
}
log('\n' + '─'.repeat(40));
log('✓ Setup complete!\n');
log(` Bank ID: ${args.bankPrefix}-openclaw`);
log(` API URL: ${args.apiUrl}`);
log('');
log(' Watch gateway logs to confirm:');
log(' grep Hindsight ~/.openclaw/logs/gateway.log | tail -5');
log(' Expected: [Hindsight] ✓ Ready (external API mode)');
log('');
log(' Test memory retention:');
log(` openclaw agent --agent main --session-id test-1 -m "My name is Ben."`);
log(` openclaw agent --agent main --session-id test-2 -m "What do you remember about me?"`);
}
@@ -0,0 +1,63 @@
export interface CliArgs {
sandbox: string;
apiUrl: string;
apiToken: string;
bankPrefix: string;
skipPolicy: boolean;
skipPluginInstall: boolean;
dryRun: boolean;
}
export interface PolicyEndpointRule {
allow: {
method: string;
path: string;
};
}
export interface PolicyEndpoint {
host: string;
port: number;
protocol?: string;
tls?: string;
enforcement?: string;
access?: string;
rules?: PolicyEndpointRule[];
}
export interface PolicyBinary {
path: string;
}
export interface NetworkPolicy {
name: string;
endpoints: PolicyEndpoint[];
binaries?: PolicyBinary[];
}
export interface FilesystemPolicy {
include_workdir?: boolean;
read_only?: string[];
read_write?: string[];
}
export interface Landlock {
compatibility?: string;
}
export interface ProcessPolicy {
run_as_user?: string;
run_as_group?: string;
}
export interface SandboxPolicy {
version?: number;
filesystem_policy?: FilesystemPolicy;
landlock?: Landlock;
process?: ProcessPolicy;
network_policies?: Record<string, NetworkPolicy>;
}
export const HINDSIGHT_POLICY_NAME = 'hindsight';
export const HINDSIGHT_HOST = 'api.hindsight.vectorize.io';
export const OPENCLAW_BINARY = '/usr/local/bin/openclaw';
@@ -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"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
@@ -57,12 +57,12 @@
},
"dynamicBankId": {
"type": "boolean",
"description": "Enable per-channel memory banks. When true, memories are isolated by channel (e.g., slack-C123, telegram-456). When false, all channels share a single 'openclaw' bank.",
"description": "Enable per-user memory banks. When true, memories are isolated by user per channel (e.g., slack-U123, telegram-456789). When false, all users share a single 'openclaw' bank.",
"default": true
},
"bankIdPrefix": {
"type": "string",
"description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-C123'). Useful for separating environments."
"description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-U123'). Useful for separating environments."
}
},
"additionalProperties": false
+3 -2
View File
@@ -34,8 +34,9 @@
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test": "vitest run src",
"test:watch": "vitest src",
"test:integration": "vitest run --config vitest.integration.config.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
@@ -1,104 +1,31 @@
import { describe, it, expect } from 'vitest';
import { HindsightClient, escapeShellArg } from './client.js';
import { HindsightClient } from './client.js';
describe('HindsightClient', () => {
it('should create instance with provider and API key', () => {
const client = new HindsightClient('openai', 'test-key', 'gpt-4');
const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4' });
expect(client).toBeInstanceOf(HindsightClient);
});
it('should set bank ID', () => {
const client = new HindsightClient('openai', 'test-key');
const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key' });
client.setBankId('test-bank');
// No error thrown means success
expect(true).toBe(true);
});
it('should handle content escaping for single quotes', () => {
const client = new HindsightClient('openai', 'test-key');
// This test validates the client is instantiated correctly
// Actual CLI calls would require mocking
expect(client).toBeDefined();
});
});
describe('escapeShellArg', () => {
it('should return unchanged string when no special characters', () => {
expect(escapeShellArg('hello world')).toBe('hello world');
expect(escapeShellArg('simple text 123')).toBe('simple text 123');
});
it('should escape single quotes', () => {
expect(escapeShellArg("it's")).toBe("it'\\''s");
expect(escapeShellArg("don't")).toBe("don'\\''t");
expect(escapeShellArg("'quoted'")).toBe("'\\''quoted'\\''");
});
it('should preserve dollar signs (protected by single quotes)', () => {
// These are NOT escaped - single quotes protect them
expect(escapeShellArg('$HOME')).toBe('$HOME');
expect(escapeShellArg('cost is $100')).toBe('cost is $100');
});
it('should preserve backticks (protected by single quotes)', () => {
expect(escapeShellArg('`ls`')).toBe('`ls`');
expect(escapeShellArg('run `command`')).toBe('run `command`');
});
it('should preserve exclamation marks (protected by single quotes)', () => {
expect(escapeShellArg('hello!')).toBe('hello!');
expect(escapeShellArg('wow! amazing!')).toBe('wow! amazing!');
});
it('should preserve glob patterns (protected by single quotes)', () => {
expect(escapeShellArg('*.txt')).toBe('*.txt');
expect(escapeShellArg('file?.log')).toBe('file?.log');
expect(escapeShellArg('[abc]')).toBe('[abc]');
});
it('should preserve parentheses and braces (protected by single quotes)', () => {
expect(escapeShellArg('(subshell)')).toBe('(subshell)');
expect(escapeShellArg('{a,b,c}')).toBe('{a,b,c}');
});
it('should preserve redirection and control operators (protected by single quotes)', () => {
expect(escapeShellArg('a > b')).toBe('a > b');
expect(escapeShellArg('cmd | grep')).toBe('cmd | grep');
expect(escapeShellArg('a && b')).toBe('a && b');
expect(escapeShellArg('a; b')).toBe('a; b');
});
it('should preserve backslashes (protected by single quotes)', () => {
expect(escapeShellArg('path\\to\\file')).toBe('path\\to\\file');
});
it('should preserve double quotes (protected by single quotes)', () => {
expect(escapeShellArg('"quoted"')).toBe('"quoted"');
});
it('should preserve hash (protected by single quotes)', () => {
expect(escapeShellArg('# comment')).toBe('# comment');
});
it('should preserve tilde (protected by single quotes)', () => {
expect(escapeShellArg('~user')).toBe('~user');
});
it('should preserve newlines (protected by single quotes)', () => {
expect(escapeShellArg('line1\nline2')).toBe('line1\nline2');
});
it('should handle complex mixed content', () => {
expect(escapeShellArg("It's $100! Run `ls`")).toBe("It'\\''s $100! Run `ls`");
expect(escapeShellArg("user's file*.txt")).toBe("user'\\''s file*.txt");
});
it('should handle empty string', () => {
expect(escapeShellArg('')).toBe('');
});
it('should handle multiple consecutive single quotes', () => {
expect(escapeShellArg("''")).toBe("'\\'''\\''");
expect(escapeShellArg("'''")).toBe("'\\'''\\'''\\''");
it('should create instance with embed package path', () => {
const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4', embedPackagePath: '/path/to/hindsight' });
expect(client).toBeInstanceOf(HindsightClient);
});
it('should create instance in HTTP mode', () => {
const client = new HindsightClient({
llmProvider: 'openai',
llmApiKey: 'test-key',
apiUrl: 'https://api.example.com/',
apiToken: 'bearer-token',
});
expect(client).toBeInstanceOf(HindsightClient);
});
});
+170 -77
View File
@@ -1,4 +1,4 @@
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, mkdir, rm } from 'fs/promises';
import { tmpdir } from 'os';
@@ -11,7 +11,13 @@ import type {
RecallResponse,
} from './types.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_BUFFER = 5 * 1024 * 1024; // 5 MB — large transcripts can exceed default 1 MB
const DEFAULT_TIMEOUT_MS = 15_000;
/** Strip null bytes from strings — Node 22 rejects them in execFile() args */
const sanitize = (s: string) => s.replace(/\0/g, '');
/**
* Sanitize a string for use as a cross-platform filename.
@@ -22,80 +28,101 @@ function sanitizeFilename(name: string): string {
return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) || 'content';
}
/**
* Escape a string for use as a single-quoted shell argument.
*
* In POSIX shells, single-quoted strings treat ALL characters literally
* except for the single quote itself. To include a literal single quote,
* we use the pattern: end quote + escaped quote + start quote = '\''
*
* Example: "It's $100" becomes 'It'\''s $100'
* Shell interprets: 'It' + \' + 's $100' = It's $100
*
* This handles ALL shell-special characters including:
* - $ (variable expansion)
* - ` (command substitution)
* - ! (history expansion)
* - ? * [ ] (glob patterns)
* - ( ) { } (subshell/brace expansion)
* - < > | & ; (redirection/control)
* - \ " # ~ newlines
*
* @param arg - The string to escape
* @returns The escaped string (without surrounding quotes - caller adds those)
*/
export function escapeShellArg(arg: string): string {
// Replace single quotes with the escape sequence: '\''
// This ends the current single-quoted string, adds an escaped literal quote,
// and starts a new single-quoted string.
return arg.replace(/'/g, "'\\''");
export interface HindsightClientOptions {
llmProvider: string;
llmApiKey: string;
llmModel?: string;
embedVersion?: string;
embedPackagePath?: string;
apiUrl?: string; // Direct HTTP mode — bypass subprocess
apiToken?: string; // Auth header for HTTP mode
}
export class HindsightClient {
private bankId: string = 'default'; // Always use default bank
private bankId: string = 'default';
private llmProvider: string;
private llmApiKey: string;
private llmModel?: string;
private embedVersion: string;
private embedPackagePath?: string;
private apiUrl?: string;
private apiToken?: string;
constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion: string = 'latest', embedPackagePath?: string) {
this.llmProvider = llmProvider;
this.llmApiKey = llmApiKey;
this.llmModel = llmModel;
this.embedVersion = embedVersion || 'latest';
this.embedPackagePath = embedPackagePath;
constructor(opts: HindsightClientOptions) {
this.llmProvider = opts.llmProvider;
this.llmApiKey = opts.llmApiKey;
this.llmModel = opts.llmModel;
this.embedVersion = opts.embedVersion || 'latest';
this.embedPackagePath = opts.embedPackagePath;
this.apiUrl = opts.apiUrl?.replace(/\/$/, ''); // strip trailing slash
this.apiToken = opts.apiToken;
}
private get httpMode(): boolean {
return !!this.apiUrl;
}
/**
* Get the command prefix to run hindsight-embed (either local or from PyPI)
* Get the command and base args to run hindsight-embed.
* Returns [command, ...baseArgs] for use with execFile/spawn (no shell).
*/
private getEmbedCommandPrefix(): string {
private getEmbedCommand(): string[] {
if (this.embedPackagePath) {
// Local package: uv run --directory <path> hindsight-embed
return `uv run --directory ${this.embedPackagePath} hindsight-embed`;
} else {
// PyPI package: uvx hindsight-embed@version
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
return `uvx ${embedPackage}`;
return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed'];
}
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
return ['uvx', embedPackage];
}
private httpHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiToken) {
headers['Authorization'] = `Bearer ${this.apiToken}`;
}
return headers;
}
setBankId(bankId: string): void {
this.bankId = bankId;
}
// --- setBankMission ---
async setBankMission(mission: string): Promise<void> {
if (!mission || mission.trim().length === 0) {
return;
}
const escapedMission = escapeShellArg(mission);
const embedCmd = this.getEmbedCommandPrefix();
const cmd = `${embedCmd} --profile openclaw bank mission ${this.bankId} '${escapedMission}'`;
if (this.httpMode) {
return this.setBankMissionHttp(mission);
}
return this.setBankMissionSubprocess(mission);
}
private async setBankMissionHttp(mission: string): Promise<void> {
try {
const { stdout } = await execAsync(cmd);
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}`;
const res = await fetch(url, {
method: 'PUT',
headers: this.httpHeaders(),
body: JSON.stringify({ mission }),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${body}`);
}
console.log(`[Hindsight] Bank mission set via HTTP`);
} catch (error) {
console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`);
}
}
private async setBankMissionSubprocess(mission: string): Promise<void> {
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)];
try {
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`);
} catch (error) {
// Don't fail if mission set fails - bank might not exist yet, will be created on first retain
@@ -103,24 +130,65 @@ export class HindsightClient {
}
}
// --- retain ---
async retain(request: RetainRequest): Promise<RetainResponse> {
if (this.httpMode) {
return this.retainHttp(request);
}
return this.retainSubprocess(request);
}
private async retainHttp(request: RetainRequest): Promise<RetainResponse> {
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories`;
const body = {
items: [{
content: request.content,
document_id: request.document_id || 'conversation',
metadata: request.metadata,
}],
async: true,
};
const res = await fetch(url, {
method: 'POST',
headers: this.httpHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to retain memory (HTTP ${res.status}): ${text}`);
}
const data = await res.json();
console.log(`[Hindsight] Retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`);
return {
message: 'Memory queued for background processing',
document_id: request.document_id || 'conversation',
memory_unit_ids: [],
};
}
private async retainSubprocess(request: RetainRequest): Promise<RetainResponse> {
const docId = request.document_id || 'conversation';
// Write content to a temp file to avoid E2BIG (ARG_MAX) errors when passing
// large conversations as shell arguments via execAsync.
// large conversations as arguments.
const tempDir = join(tmpdir(), `hindsight_${randomBytes(8).toString('hex')}`);
const safeFilename = sanitizeFilename(docId);
const tempFile = join(tempDir, `${safeFilename}.txt`);
try {
await mkdir(tempDir, { recursive: true });
await writeFile(tempFile, request.content, 'utf8');
await writeFile(tempFile, sanitize(request.content), 'utf8');
const escapedTempFile = escapeShellArg(tempFile);
const embedCmd = this.getEmbedCommandPrefix();
const cmd = `${embedCmd} --profile openclaw memory retain-files ${this.bankId} '${escapedTempFile}' --async`;
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async'];
const { stdout } = await execAsync(cmd);
const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER });
console.log(`[Hindsight] Retained (async): ${stdout.trim()}`);
return {
@@ -129,39 +197,64 @@ export class HindsightClient {
memory_unit_ids: [],
};
} catch (error) {
throw new Error(`Failed to retain memory: ${error}`);
throw new Error(`Failed to retain memory: ${error}`, { cause: error });
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
async recall(request: RecallRequest): Promise<RecallResponse> {
const query = escapeShellArg(request.query);
const maxTokens = request.max_tokens || 1024;
// --- recall ---
const embedCmd = this.getEmbedCommandPrefix();
const cmd = `${embedCmd} --profile openclaw memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
async recall(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
if (this.httpMode) {
return this.recallHttp(request, timeoutMs);
}
return this.recallSubprocess(request, timeoutMs);
}
private async recallHttp(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories/recall`;
// Defense-in-depth: truncate query to stay under API's 500-token limit
const MAX_QUERY_CHARS = 800;
const query = request.query.length > MAX_QUERY_CHARS
? (console.warn(`[Hindsight] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`),
request.query.substring(0, MAX_QUERY_CHARS))
: request.query;
const body = {
query,
max_tokens: request.max_tokens || 1024,
};
const res = await fetch(url, {
method: 'POST',
headers: this.httpHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
}
return res.json() as Promise<RecallResponse>;
}
private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
const query = sanitize(request.query);
const maxTokens = request.max_tokens || 1024;
const [cmd, ...baseArgs] = this.getEmbedCommand();
const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'recall', this.bankId, query, '--output', 'json', '--max-tokens', String(maxTokens)];
try {
const { stdout } = await execAsync(cmd);
const { stdout } = await execFileAsync(cmd, args, {
maxBuffer: MAX_BUFFER,
timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
});
// Parse JSON output - returns { entities: {...}, results: [...] }
const response = JSON.parse(stdout);
const results = response.results || [];
return {
results: results.map((r: any) => ({
content: r.text || r.content || '',
score: 1.0, // CLI doesn't return scores
metadata: {
document_id: r.document_id,
chunk_id: r.chunk_id,
...r.metadata,
},
})),
};
return JSON.parse(stdout) as RecallResponse;
} catch (error) {
throw new Error(`Failed to recall memories: ${error}`);
throw new Error(`Failed to recall memories: ${error}`, { cause: error });
}
}
}
+115 -55
View File
@@ -1,83 +1,143 @@
import { describe, it, expect } from 'vitest';
import { stripMemoryTags, extractRecallQuery } from './index.js';
/**
* Unit tests for the memory feedback loop fix.
* Verifies that <hindsight_memories> and <relevant_memories> tags
* are stripped from content before RETAIN to prevent duplicates.
*/
describe('Memory Tag Stripping', () => {
/**
* Simulates the tag stripping logic from agent_end hook
*/
function stripMemoryTags(content: string): string {
// Strip plugin-injected memory tags to prevent feedback loop
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
// ---------------------------------------------------------------------------
// stripMemoryTags
// ---------------------------------------------------------------------------
it('should strip simple hindsight_memories tags', () => {
const input = 'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
const expected = 'User: Hello\n\nAssistant: How can I help?';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
describe('stripMemoryTags', () => {
it('strips simple hindsight_memories tags', () => {
const input =
'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
expect(stripMemoryTags(input)).toBe('User: Hello\n\nAssistant: How can I help?');
});
it('should strip relevant_memories tags', () => {
it('strips relevant_memories tags', () => {
const input = 'Before\n<relevant_memories>\nSome data\n</relevant_memories>\nAfter';
const expected = 'Before\n\nAfter';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(stripMemoryTags(input)).toBe('Before\n\nAfter');
});
it('should strip multiple hindsight_memories blocks', () => {
const input = 'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
const expected = 'Start\n\nMiddle\n\nEnd';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
it('strips multiple hindsight_memories blocks', () => {
const input =
'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
expect(stripMemoryTags(input)).toBe('Start\n\nMiddle\n\nEnd');
});
it('should handle multiline memory blocks with JSON', () => {
const input = 'User: What is the weather?\n<hindsight_memories>\nRelevant memories:\n{\n "memory": "User likes sunny weather"\n}\n</hindsight_memories>\nAssistant: Let me check';
const expected = 'User: What is the weather?\n\nAssistant: Let me check';
it('handles multiline memory blocks with JSON', () => {
const input =
'User: What is the weather?\n<hindsight_memories>\n[\n {"memory": "User likes sunny weather"}\n]\n</hindsight_memories>\nAssistant: Let me check';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(result).toBe('User: What is the weather?\n\nAssistant: Let me check');
});
it('should preserve content without memory tags', () => {
it('preserves content without memory tags', () => {
const input = 'User: Hello\nAssistant: Hi there!';
const expected = 'User: Hello\nAssistant: Hi there!';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(stripMemoryTags(input)).toBe(input);
});
it('should handle nested-like content without actual nesting', () => {
const input = '<hindsight_memories>Outer start\n</hindsight_memories>\nSafe content\n<hindsight_memories>\nOuter end</hindsight_memories>';
const expected = '\nSafe content\n';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
it('strips both tag types in same content', () => {
const input =
'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
expect(stripMemoryTags(input)).toBe('A\n\nB\n\nC');
});
it('should strip both tag types in same content', () => {
const input = 'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
const expected = 'A\n\nB\n\nC';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
});
it('should handle real-world agent conversation with injected memories', () => {
const input = '[role: system]\n<hindsight_memories>\nRelevant memories from past conversations (score 1=highest, prioritize recent when conflicting):\n[\n {\n "content": "User prefers dark mode",\n "relevance_score": 0.95\n }\n]\n\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nBased on your previous preference, let me help you enable dark mode.\n[assistant:end]';
it('strips tags from a real-world agent conversation with injected memories', () => {
const input =
'[role: system]\n<hindsight_memories>\nRelevant memories:\n[{"text": "User prefers dark mode"}]\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nLet me help you enable dark mode.\n[assistant:end]';
const result = stripMemoryTags(input);
// Should not contain the memory tags
expect(result).not.toContain('<hindsight_memories>');
expect(result).not.toContain('</hindsight_memories>');
expect(result).not.toContain('Relevant memories from past conversations');
// Should still contain the actual conversation
expect(result).not.toContain('User prefers dark mode');
expect(result).toContain('[role: user]');
expect(result).toContain('How do I enable dark mode?');
expect(result).toContain('[role: assistant]');
});
});
// ---------------------------------------------------------------------------
// extractRecallQuery
// ---------------------------------------------------------------------------
describe('extractRecallQuery', () => {
it('returns rawMessage when it is long enough', () => {
expect(extractRecallQuery('What is my favorite food?', undefined)).toBe(
'What is my favorite food?',
);
});
it('returns null when rawMessage is too short and prompt is absent', () => {
expect(extractRecallQuery('Hi', undefined)).toBeNull();
expect(extractRecallQuery('', '')).toBeNull();
expect(extractRecallQuery(undefined, undefined)).toBeNull();
});
it('returns null when both rawMessage and prompt are too short', () => {
expect(extractRecallQuery('Hey', 'Hey')).toBeNull();
});
it('falls back to prompt when rawMessage is absent', () => {
const result = extractRecallQuery(undefined, 'What programming language do I prefer?');
expect(result).toBe('What programming language do I prefer?');
});
it('strips leading System: lines from prompt', () => {
const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).toContain('What is my name?');
});
it('strips [Channel] envelope header and returns inner message', () => {
const prompt = '[Telegram Chat]\nWhat is my favorite hobby?';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBe('What is my favorite hobby?');
});
it('strips [from: SenderName] footer from group chat prompts', () => {
const prompt = '[Slack Channel #general]\nWhat should I eat for lunch?\n[from: Alice]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('[from: Alice]');
expect(result).toContain('What should I eat for lunch?');
});
it('handles full envelope with System lines, channel header, and from footer', () => {
const prompt =
'System: You are a helpful agent.\n\n[Discord Server]\nRemind me what I said about Python?\n[from: Bob]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).not.toContain('[Discord');
expect(result).not.toContain('[from: Bob]');
expect(result).toContain('Remind me what I said about Python?');
});
it('strips session abort hint from prompt', () => {
const prompt =
'Note: The previous agent run was aborted by the user\n\n[Telegram]\nWhat is my cat\'s name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('Note: The previous agent run was aborted');
expect(result).toContain("What is my cat's name?");
});
it('returns null when prompt reduces to < 5 chars after stripping', () => {
// Envelope with almost-empty inner message
const prompt = '[Telegram Chat]\nHi';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBeNull();
});
it('prefers rawMessage over prompt even when prompt is longer', () => {
const rawMessage = 'What do I like to eat?';
const prompt = '[Telegram]\nWhat do I like to eat?\n[from: Alice]';
const result = extractRecallQuery(rawMessage, prompt);
// Should return the clean rawMessage verbatim
expect(result).toBe(rawMessage);
expect(result).not.toContain('[from: Alice]');
});
it('trims whitespace from result', () => {
const result = extractRecallQuery(' What is my job? ', undefined);
expect(result).toBe('What is my job?');
});
});
+205 -61
View File
@@ -1,6 +1,6 @@
import type { MoltbotPluginAPI, PluginConfig } from './types.js';
import { HindsightEmbedManager } from './embed-manager.js';
import { HindsightClient } from './client.js';
import { HindsightClient, type HindsightClientOptions } from './client.js';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -17,13 +17,90 @@ let currentPluginConfig: PluginConfig | null = null;
// Track which banks have had their mission set (to avoid re-setting on every request)
const banksWithMissionSet = new Set<string>();
// In-flight recall deduplication: concurrent recalls for the same bank reuse one promise
import type { RecallResponse } from './types.js';
const inflightRecalls = new Map<string, Promise<RecallResponse>>();
const RECALL_TIMEOUT_MS = 10_000;
// Cooldown + guard to prevent concurrent reinit attempts
let lastReinitAttempt = 0;
let isReinitInProgress = false;
const REINIT_COOLDOWN_MS = 30_000;
/**
* Lazy re-initialization after startup failure.
* Called by waitForReady when initPromise rejected but API may now be reachable.
* Throttled to one attempt per 30s to avoid hammering a down service.
*/
async function lazyReinit(): Promise<void> {
const now = Date.now();
if (now - lastReinitAttempt < REINIT_COOLDOWN_MS || isReinitInProgress) {
return;
}
isReinitInProgress = true;
lastReinitAttempt = now;
const config = currentPluginConfig;
if (!config) {
isReinitInProgress = false;
return;
}
const externalApi = detectExternalApi(config);
if (!externalApi.apiUrl) {
isReinitInProgress = false;
return; // Only external API mode supports lazy reinit
}
console.log('[Hindsight] Attempting lazy re-initialization...');
try {
await checkExternalApiHealth(externalApi.apiUrl);
// Health check passed — set up env vars and create client
process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
if (externalApi.apiToken) {
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
}
const llmConfig = detectLLMConfig(config);
client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi));
const defaultBankId = deriveBankId(undefined, config);
client.setBankId(defaultBankId);
if (config.bankMission && !config.dynamicBankId) {
await client.setBankMission(config.bankMission);
}
usingExternalApi = true;
isInitialized = true;
// Replace the rejected initPromise with a resolved one
initPromise = Promise.resolve();
console.log('[Hindsight] ✓ Lazy re-initialization succeeded');
} catch (error) {
console.warn(`[Hindsight] Lazy re-initialization failed (will retry in ${REINIT_COOLDOWN_MS / 1000}s):`, error instanceof Error ? error.message : error);
} finally {
isReinitInProgress = false;
}
}
// Global access for hooks (Moltbot loads hooks separately)
if (typeof global !== 'undefined') {
(global as any).__hindsightClient = {
getClient: () => client,
waitForReady: async () => {
if (isInitialized) {return;}
if (initPromise) {await initPromise;}
if (initPromise) {
try {
await initPromise;
} catch {
// Init failed (e.g., health check timeout at startup).
// Attempt lazy re-initialization so Hindsight recovers
// once the API becomes reachable again.
if (!isInitialized) {
await lazyReinit();
}
}
}
},
/**
* Get a client configured for a specific agent context.
@@ -61,6 +138,67 @@ const __dirname = dirname(__filename);
// Default bank name (fallback when channel context not available)
const DEFAULT_BANK_NAME = 'openclaw';
/**
* Strip plugin-injected memory tags from content to prevent retain feedback loop.
* Removes <hindsight_memories> and <relevant_memories> blocks that were injected
* during before_agent_start so they don't get re-stored into the memory bank.
*/
export function stripMemoryTags(content: string): string {
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
/**
* Extract a recall query from a hook event's rawMessage or prompt.
*
* Prefers rawMessage (clean user text). Falls back to prompt, stripping
* envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
*
* Returns null when no usable query (< 5 chars) can be extracted.
*/
export function extractRecallQuery(
rawMessage: string | undefined,
prompt: string | undefined,
): string | null {
let recallQuery = rawMessage;
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
recallQuery = prompt;
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
return null;
}
// Strip envelope-formatted prompts from any channel
let cleaned = recallQuery;
// Remove leading "System: ..." lines (from prependSystemEvents)
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
// Remove session abort hint
cleaned = cleaned.replace(
/^Note: The previous agent run was aborted[^\n]*\n\n/,
'',
);
// Extract message after [ChannelName ...] envelope header
const envelopeMatch = cleaned.match(
/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/,
);
if (envelopeMatch) {
cleaned = envelopeMatch[1];
}
// Remove trailing [from: SenderName] metadata (group chats)
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
recallQuery = cleaned.trim() || recallQuery;
}
const trimmed = recallQuery.trim();
if (trimmed.length < 5) return null;
return trimmed;
}
/**
* Agent context passed to plugin hooks.
* These fields are populated by OpenClaw when invoking hooks.
@@ -76,7 +214,7 @@ interface PluginHookAgentContext {
/**
* Derive a bank ID from the agent context.
* Creates channel-specific banks: {messageProvider}-{channelId}
* Creates per-user banks: {messageProvider}-{senderId}
* Falls back to default bank when context is unavailable.
*/
function deriveBankId(
@@ -91,10 +229,10 @@ function deriveBankId(
}
const channelType = ctx?.messageProvider || 'unknown';
const channelId = ctx?.channelId || 'default';
const userId = ctx?.senderId || 'default';
// Build bank ID: {prefix?}-{channelType}-{channelId}
const baseBankId = `${channelType}-${channelId}`;
// Build bank ID: {prefix?}-{channelType}-{senderId}
const baseBankId = `${channelType}-${userId}`;
return pluginConfig.bankIdPrefix
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
: baseBankId;
@@ -233,6 +371,25 @@ function detectExternalApi(pluginConfig?: PluginConfig): {
return { apiUrl, apiToken };
}
/**
* Build HindsightClientOptions from LLM config, plugin config, and external API settings.
*/
function buildClientOptions(
llmConfig: { provider: string; apiKey: string; model?: string },
pluginCfg: PluginConfig,
externalApi: { apiUrl: string | null; apiToken: string | null },
): HindsightClientOptions {
return {
llmProvider: llmConfig.provider,
llmApiKey: llmConfig.apiKey,
llmModel: llmConfig.model,
embedVersion: pluginCfg.embedVersion,
embedPackagePath: pluginCfg.embedPackagePath,
apiUrl: externalApi.apiUrl ?? undefined,
apiToken: externalApi.apiToken ?? undefined,
};
}
/**
* Health check for external Hindsight API.
* Retries up to 3 times with 2s delay — container DNS may not be ready on first boot.
@@ -352,9 +509,9 @@ export default function (api: MoltbotPluginAPI) {
console.log('[Hindsight] External API mode - skipping local daemon...');
await checkExternalApiHealth(externalApi.apiUrl);
// Initialize client (CLI commands will use external API via env vars)
console.log('[Hindsight] Creating HindsightClient...');
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
// Initialize client with direct HTTP mode
console.log('[Hindsight] Creating HindsightClient (HTTP mode)...');
client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi));
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
const defaultBankId = deriveBankId(undefined, pluginConfig);
@@ -388,9 +545,9 @@ export default function (api: MoltbotPluginAPI) {
console.log('[Hindsight] Starting embedded server...');
await embedManager.start();
// Initialize client
console.log('[Hindsight] Creating HindsightClient...');
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
// Initialize client (local daemon mode — no apiUrl)
console.log('[Hindsight] Creating HindsightClient (subprocess mode)...');
client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null }));
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
const defaultBankId = deriveBankId(undefined, pluginConfig);
@@ -484,7 +641,7 @@ export default function (api: MoltbotPluginAPI) {
await checkExternalApiHealth(externalApi.apiUrl);
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi));
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
client.setBankId(defaultBankId);
@@ -509,7 +666,7 @@ export default function (api: MoltbotPluginAPI) {
await embedManager.start();
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null }));
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
client.setBankId(defaultBankId);
@@ -573,43 +730,18 @@ export default function (api: MoltbotPluginAPI) {
const bankId = deriveBankId(ctx, pluginConfig);
console.log(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
// Get the user's latest message for recall
// Prefer rawMessage (clean user text) over prompt (envelope-formatted)
let prompt = event.rawMessage ?? event.prompt;
if (!prompt || typeof prompt !== 'string' || prompt.length < 5) {
return; // Skip very short messages
// Get the user's latest message for recall — only the raw user text, not the full prompt
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
if (!extracted) {
return;
}
let prompt = extracted;
// Strip envelope-formatted prompts from any channel
// The prompt may contain: System: lines, abort hints, [Channel ...] header, [from: ...] suffix
let cleaned = prompt;
// Remove leading "System: ..." lines (from prependSystemEvents)
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
// Remove session abort hint
cleaned = cleaned.replace(
/^Note: The previous agent run was aborted[^\n]*\n\n/,
'',
);
// Extract message after [ChannelName ...] envelope header
// Handles any channel: Telegram, Slack, Discord, WhatsApp, Signal, etc.
// Uses [\s\S]+ instead of .+ to support multiline messages
const envelopeMatch = cleaned.match(
/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/,
);
if (envelopeMatch) {
cleaned = envelopeMatch[1];
}
// Remove trailing [from: SenderName] metadata (group chats)
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
prompt = cleaned.trim() || prompt;
if (prompt.length < 5) {
return; // Skip very short messages after extraction
// Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
const MAX_RECALL_QUERY_CHARS = 800;
if (prompt.length > MAX_RECALL_QUERY_CHARS) {
prompt = prompt.substring(0, MAX_RECALL_QUERY_CHARS);
}
// Wait for client to be ready
@@ -630,11 +762,20 @@ export default function (api: MoltbotPluginAPI) {
console.log(`[Hindsight] Auto-recall for bank ${bankId}, prompt: ${prompt.substring(0, 50)}`);
// Recall relevant memories
const response = await client.recall({
query: prompt,
max_tokens: 2048,
});
// Recall with deduplication: reuse in-flight request for same bank
const recallKey = bankId;
const existing = inflightRecalls.get(recallKey);
let recallPromise: Promise<RecallResponse>;
if (existing) {
console.log(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
recallPromise = existing;
} else {
recallPromise = client.recall({ query: prompt, max_tokens: 2048 }, RECALL_TIMEOUT_MS);
inflightRecalls.set(recallKey, recallPromise);
void recallPromise.catch(() => {}).finally(() => inflightRecalls.delete(recallKey));
}
const response = await recallPromise;
if (!response.results || response.results.length === 0) {
console.log('[Hindsight] No memories found for auto-recall');
@@ -645,7 +786,7 @@ export default function (api: MoltbotPluginAPI) {
const memoriesJson = JSON.stringify(response.results, null, 2);
const contextMessage = `<hindsight_memories>
Relevant memories from past conversations (score 1=highest, prioritize recent when conflicting):
Relevant memories from past conversations (prioritize recent when conflicting):
${memoriesJson}
User message: ${prompt}
@@ -656,7 +797,13 @@ User message: ${prompt}
// Inject context before the user message
return { prependContext: contextMessage };
} catch (error) {
console.error('[Hindsight] Auto-recall error:', error);
if (error instanceof DOMException && error.name === 'TimeoutError') {
console.warn(`[Hindsight] Auto-recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
} else if (error instanceof Error && error.name === 'AbortError') {
console.warn(`[Hindsight] Auto-recall aborted after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`);
} else {
console.error('[Hindsight] Auto-recall error:', error);
}
return;
}
});
@@ -716,10 +863,7 @@ User message: ${prompt}
}
// Strip plugin-injected memory tags to prevent feedback loop
// Remove <hindsight_memories> blocks injected during before_agent_start
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
// Remove any <relevant_memories> blocks (legacy/alternative format)
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
content = stripMemoryTags(content);
return `[role: ${role}]\n${content}\n[${role}:end]`;
})
@@ -740,7 +884,7 @@ User message: ${prompt}
document_id: documentId,
metadata: {
retained_at: new Date().toISOString(),
message_count: event.messages.length,
message_count: String(event.messages.length),
channel_type: effectiveCtx?.messageProvider,
channel_id: effectiveCtx?.channelId,
sender_id: effectiveCtx?.senderId,
+15 -7
View File
@@ -72,16 +72,24 @@ export interface RecallRequest {
export interface RecallResponse {
results: MemoryResult[];
entities: Record<string, unknown> | null;
trace: unknown | null;
chunks: unknown | null;
}
export interface MemoryResult {
content: string;
score: number;
metadata?: {
document_id?: string;
created_at?: string;
source?: string;
};
id: string;
text: string;
type: string;
entities: string[];
context: string;
occurred_start: string | null;
occurred_end: string | null;
mentioned_at: string | null;
document_id: string | null;
metadata: Record<string, unknown> | null;
chunk_id: string | null;
tags: string[];
}
export interface CreateBankRequest {
@@ -0,0 +1,542 @@
/**
* Integration tests for the OpenClaw plugin hooks.
*
* Loads the plugin with a mock MoltbotPluginAPI in HTTP mode, then triggers
* `before_agent_start` and `agent_end` hooks with realistic event payloads.
* Client methods (recall / retain) are spied on to verify the plugin
* orchestrates them correctly without requiring a full LLM pipeline.
*
* Requirements:
* Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import type { HindsightClient } from '../src/client.js';
import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js';
import type { RecallResponse, RetainResponse } from '../src/types.js';
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
/* not ready yet */
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
interface MockApiHandle {
api: MoltbotPluginAPI;
/** Trigger a registered hook and return the last handler's return value. */
trigger(event: string, eventData: unknown, ctx?: unknown): Promise<unknown>;
startServices(): Promise<void>;
stopServices(): Promise<void>;
}
function createMockApi(pluginConfig: Partial<PluginConfig> = {}): MockApiHandle {
const handlers = new Map<string, ((event: unknown, ctx?: unknown) => unknown)[]>();
const services: { id: string; start(): Promise<void>; stop(): Promise<void> }[] = [];
const api: MoltbotPluginAPI = {
config: {
plugins: {
entries: {
'hindsight-openclaw': { enabled: true, config: pluginConfig as PluginConfig },
},
},
},
registerService(svc: any) {
services.push(svc);
},
on(event: string, handler: any) {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
};
return {
api,
async trigger(event, eventData, ctx) {
const list = handlers.get(event) ?? [];
let result: unknown;
for (const h of list) result = await h(eventData, ctx);
return result;
},
async startServices() {
for (const svc of services) await svc.start();
},
async stopServices() {
for (const svc of services) await svc.stop();
},
};
}
const EMPTY_RECALL: RecallResponse = { results: [], entities: null, trace: null, chunks: null };
const OK_RETAIN: RetainResponse = { message: 'queued', document_id: 'test', memory_unit_ids: [] };
function makeMemoryResult(text: string) {
return {
id: `mem-${Math.random().toString(36).slice(2)}`,
text,
type: 'fact',
entities: [],
context: '',
occurred_start: null,
occurred_end: null,
mentioned_at: null,
document_id: null,
metadata: null,
chunk_id: null,
tags: [],
};
}
// ---------------------------------------------------------------------------
// Module-level state shared across all hook describe blocks
// ---------------------------------------------------------------------------
let apiReachable = false;
let triggerHook: MockApiHandle['trigger'];
let stopServicesFn: () => Promise<void>;
let recallSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
let retainSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
beforeAll(async () => {
apiReachable = await waitForApi(HINDSIGHT_API_URL, 8000);
if (!apiReachable) {
console.warn(
`[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} skipping hook tests.`,
);
return;
}
// Reset module registry so we get a fresh module with clean state.
vi.resetModules();
// Provide LLM config — used by plugin init even in HTTP mode.
process.env.HINDSIGHT_API_LLM_PROVIDER = 'openai';
process.env.HINDSIGHT_API_LLM_API_KEY = 'test-key-hooks';
// Point the plugin at the running test API.
process.env.HINDSIGHT_EMBED_API_URL = HINDSIGHT_API_URL;
const mod = await import('../src/index.js');
const pluginFn = mod.default;
const getClient = mod.getClient;
const handle = createMockApi({
dynamicBankId: true,
excludeProviders: ['slack'],
// No bankMission — keeps init lean
});
triggerHook = handle.trigger;
stopServicesFn = handle.stopServices;
// Load the plugin — registers hooks and starts background init.
pluginFn(handle.api);
// service.start() awaits initPromise and health-checks the external API.
await handle.startServices();
// After startServices the client must be ready.
const c = getClient();
if (!c) throw new Error('[Hooks Integration] Client not initialized after service start');
recallSpy = vi.spyOn(c, 'recall') as ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
retainSpy = vi.spyOn(c, 'retain') as ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
}, 30_000);
afterAll(async () => {
vi.restoreAllMocks();
delete process.env.HINDSIGHT_API_LLM_PROVIDER;
delete process.env.HINDSIGHT_API_LLM_API_KEY;
delete process.env.HINDSIGHT_EMBED_API_URL;
if (stopServicesFn) await stopServicesFn().catch(() => {});
}, 15_000);
afterEach(() => {
// Reset spy call history between tests; don't remove the implementation.
recallSpy?.mockReset();
retainSpy?.mockReset();
});
// ---------------------------------------------------------------------------
// before_agent_start
// ---------------------------------------------------------------------------
describe('before_agent_start hook', () => {
it('skips recall for excluded providers and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What are my preferences?', prompt: 'What are my preferences?' },
{ messageProvider: 'slack', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('skips recall when rawMessage is too short and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'Hi', prompt: 'Hi' },
{ messageProvider: 'telegram', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('returns undefined when recall finds no results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I like?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U002' },
);
expect(recallSpy).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it('returns { prependContext } with <hindsight_memories> when recall returns results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User likes Python')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I prefer?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U003' },
)) as { prependContext: string };
expect(result).toBeDefined();
expect(result.prependContext).toContain('<hindsight_memories>');
expect(result.prependContext).toContain('User likes Python');
expect(result.prependContext).toContain('</hindsight_memories>');
});
it('injects all memory result fields in the prependContext JSON', async () => {
if (!apiReachable) return;
const mem = makeMemoryResult('User prefers dark mode');
mem.tags = ['preference'];
mem.entities = ['dark_mode'];
recallSpy.mockResolvedValue({
results: [mem],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U004' },
)) as { prependContext: string };
// The prependContext should be valid JSON containing all MemoryResult fields
const jsonStart = result.prependContext.indexOf('[');
const jsonEnd = result.prependContext.lastIndexOf(']') + 1;
const parsed = JSON.parse(result.prependContext.slice(jsonStart, jsonEnd)) as unknown[];
expect(parsed).toHaveLength(1);
const first = parsed[0] as Record<string, unknown>;
expect(first.id).toBe(mem.id);
expect(first.text).toBe('User prefers dark mode');
expect(first.type).toBe('fact');
expect(first.tags).toEqual(['preference']);
expect(first.entities).toEqual(['dark_mode']);
});
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const envelopePrompt = '[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]';
await triggerHook(
'before_agent_start',
{ rawMessage: '', prompt: envelopePrompt },
{ messageProvider: 'telegram', senderId: 'U005' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
// The query passed to recall must NOT contain envelope artifacts
expect(callArgs.query).not.toContain('[Telegram');
expect(callArgs.query).not.toContain('[from: Alice]');
expect(callArgs.query).toContain('What is my favorite food?');
});
it('passes max_tokens to recall', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_agent_start',
{ rawMessage: 'Tell me about my hobbies please.', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U006' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
expect(callArgs.max_tokens).toBeGreaterThan(0);
});
it('includes the user message in the prependContext block', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User loves hiking')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U007' },
)) as { prependContext: string };
expect(result.prependContext).toContain('What outdoor activities do I enjoy?');
});
});
// ---------------------------------------------------------------------------
// agent_end hook
// ---------------------------------------------------------------------------
describe('agent_end hook', () => {
it('skips retain when success is false', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: false, messages: [{ role: 'user', content: 'Hello there world!' }] },
{ messageProvider: 'telegram', senderId: 'U010' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain when messages array is empty', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: true, messages: [] },
{ messageProvider: 'telegram', senderId: 'U011' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain for excluded providers', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'I work as a software engineer.' }],
},
{ messageProvider: 'slack', senderId: 'U012' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('calls retain with correctly formatted transcript for string content', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'I love TypeScript.' },
{ role: 'assistant', content: 'TypeScript is great!' },
],
},
{ messageProvider: 'telegram', senderId: 'U013', sessionKey: 'sess-ts-test' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('[role: user]');
expect(req.content).toContain('I love TypeScript.');
expect(req.content).toContain('[user:end]');
expect(req.content).toContain('[role: assistant]');
expect(req.content).toContain('TypeScript is great!');
expect(req.content).toContain('[assistant:end]');
});
it('includes session key in document_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My favourite colour is blue.' }],
},
{ messageProvider: 'telegram', senderId: 'U014', sessionKey: 'sess-colour' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.document_id).toContain('sess-colour');
});
it('populates metadata with channel_type, channel_id, and sender_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My cat is named Whiskers.' }],
},
{
messageProvider: 'telegram',
channelId: 'chat-999',
senderId: 'U015',
sessionKey: 'sess-cat',
},
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.metadata?.channel_type).toBe('telegram');
expect(req.metadata?.channel_id).toBe('chat-999');
expect(req.metadata?.sender_id).toBe('U015');
expect(req.metadata?.retained_at).toBeDefined();
expect(req.metadata?.message_count).toBe('1');
});
it('strips <hindsight_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithMemories =
'<hindsight_memories>\nRelevant memories:\n[{"text":"old fact"}]\n</hindsight_memories>\nI enjoy reading science fiction.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithMemories }],
},
{ messageProvider: 'telegram', senderId: 'U016', sessionKey: 'sess-strip' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<hindsight_memories>');
expect(req.content).not.toContain('</hindsight_memories>');
expect(req.content).not.toContain('old fact');
expect(req.content).toContain('I enjoy reading science fiction.');
});
it('strips <relevant_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithLegacyTag =
'<relevant_memories>\nSome old memories\n</relevant_memories>\nI am learning Rust.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithLegacyTag }],
},
{ messageProvider: 'telegram', senderId: 'U017', sessionKey: 'sess-legacy' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<relevant_memories>');
expect(req.content).toContain('I am learning Rust.');
});
it('handles array content blocks (structured message format)', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'I prefer dark mode in all my editors.' },
{ type: 'image', source: 'data:...' }, // non-text block — should be ignored
],
},
],
},
{ messageProvider: 'telegram', senderId: 'U018', sessionKey: 'sess-array' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('I prefer dark mode in all my editors.');
// Image block text should not appear
expect(req.content).not.toContain('data:');
});
it('retains a multi-turn conversation in the correct transcript format', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'My name is Carol.' },
{ role: 'assistant', content: 'Nice to meet you, Carol!' },
{ role: 'user', content: 'I work as a data scientist.' },
{ role: 'assistant', content: "That's a fascinating career!" },
],
},
{ messageProvider: 'telegram', senderId: 'U019', sessionKey: 'sess-multi' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
// Each message should appear in the correct envelope format
expect(req.content).toContain('[role: user]\nMy name is Carol.\n[user:end]');
expect(req.content).toContain('[role: assistant]\nNice to meet you, Carol!\n[assistant:end]');
expect(req.content).toContain('[role: user]\nI work as a data scientist.\n[user:end]');
expect(req.metadata?.message_count).toBe('4');
});
});
@@ -0,0 +1,385 @@
/**
* Integration tests for the Hindsight OpenClaw integration.
*
* Tests both HTTP mode (direct API calls) and Embed mode (subprocess/daemon).
*
* Requirements:
* HTTP mode: Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
* Embed mode: hindsight-embed package at HINDSIGHT_EMBED_PACKAGE_PATH
* + LLM credentials (HINDSIGHT_API_LLM_PROVIDER / HINDSIGHT_API_LLM_API_KEY)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HindsightClient } from '../src/client.js';
import { HindsightEmbedManager } from '../src/embed-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ---------------------------------------------------------------------------
// Test configuration (driven by environment variables)
// ---------------------------------------------------------------------------
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || '';
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || '';
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || '';
// Embed package path defaults to the sibling hindsight-embed directory in the repo
const EMBED_PACKAGE_PATH =
process.env.HINDSIGHT_EMBED_PACKAGE_PATH ||
join(__dirname, '..', '..', '..', 'hindsight-embed');
// Port for the test embed daemon (different from production default 9077 to avoid conflicts)
const EMBED_TEST_PORT = 19077;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomBankId(): string {
return `openclaw_test_${Math.random().toString(36).slice(2, 14)}`;
}
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
// ---------------------------------------------------------------------------
// HTTP Mode Tests
// ---------------------------------------------------------------------------
describe('HindsightClient HTTP Mode', () => {
let client: HindsightClient;
beforeAll(async () => {
const reachable = await waitForApi(HINDSIGHT_API_URL);
if (!reachable) {
throw new Error(
`Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` +
'Start the server before running integration tests.',
);
}
client = new HindsightClient({
llmProvider: LLM_PROVIDER || 'openai',
llmApiKey: LLM_API_KEY || 'test-key',
llmModel: LLM_MODEL || undefined,
apiUrl: HINDSIGHT_API_URL,
});
});
it('should retain a conversation', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' +
'[role: assistant]\nNice to meet you, Alice!\n[assistant:end]',
document_id: 'http-retain-test-1',
metadata: { channel_type: 'slack', sender_id: 'U001' },
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('http-retain-test-1');
});
it('should retain with auto-generated document id', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI work at TechCorp as a software engineer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
});
it('should recall from an empty bank without error', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should set bank mission without throwing', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// setBankMission on a non-existent bank logs a warning but does not throw
await expect(
client.setBankMission('You are an assistant helping users via Slack.'),
).resolves.not.toThrow();
});
it('should set bank mission after retain creates the bank', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// Create the bank by retaining something first
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
// Now set the mission bank exists so this should succeed
await expect(
client.setBankMission('You are a helpful AI assistant.'),
).resolves.not.toThrow();
});
it('should retain and then recall relevant memories', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' +
'[role: assistant]\nPython is a great choice!\n[assistant:end]',
document_id: `session-${Date.now()}`,
});
const response = await client.recall({
query: 'What programming language do I like?',
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should silently truncate recall queries over 800 chars', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const longQuery = 'Tell me about my interests. '.repeat(50); // > 800 chars
const response = await client.recall({ query: longQuery, max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should use custom max_tokens in recall request', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'anything', max_tokens: 256 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should map recall results to MemoryResult shape', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' +
'[role: assistant]\nSounds like a great hobby!\n[assistant:end]',
document_id: 'mapping-test',
});
const response = await client.recall({ query: 'What are my hobbies?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Embed Mode Tests (subprocess / daemon)
// ---------------------------------------------------------------------------
describe('HindsightClient Embed Mode (Subprocess)', () => {
let client: HindsightClient;
let embedManager: HindsightEmbedManager;
const hasEmbedCredentials = Boolean(LLM_PROVIDER && LLM_API_KEY);
beforeAll(async () => {
if (!hasEmbedCredentials) {
console.warn(
'[Integration] Skipping embed mode tests: ' +
'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.',
);
return;
}
embedManager = new HindsightEmbedManager(
EMBED_TEST_PORT,
LLM_PROVIDER,
LLM_API_KEY,
LLM_MODEL || undefined,
undefined, // no custom base URL
0, // never idle-timeout
'latest',
EMBED_PACKAGE_PATH,
);
await embedManager.start();
client = new HindsightClient({
llmProvider: LLM_PROVIDER,
llmApiKey: LLM_API_KEY,
llmModel: LLM_MODEL || undefined,
embedPackagePath: EMBED_PACKAGE_PATH,
});
}, 120_000); // daemon startup can take up to 2 minutes
afterAll(async () => {
if (embedManager) {
await embedManager.stop();
}
}, 30_000);
it('should retain a conversation via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' +
'[role: assistant]\nSounds adventurous!\n[assistant:end]',
document_id: 'embed-retain-test-1',
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('embed-retain-test-1');
}, 60_000);
it('should retain with auto-generated document id via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI am a TypeScript developer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
}, 60_000);
it('should recall from an empty bank without error via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should set bank mission via subprocess without throwing', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Create bank by retaining first, then set mission
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
await expect(
client.setBankMission('Test mission for embed integration tests.'),
).resolves.not.toThrow();
}, 60_000);
it('should retain and then recall relevant memories via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' +
'[role: assistant]\nWhat a lovely name!\n[assistant:end]',
document_id: `embed-e2e-${Date.now()}`,
});
const response = await client.recall({
query: "What is my cat's name?",
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should map recall results to MemoryResult shape via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy cooking Italian food.\n[user:end]\n\n' +
'[role: assistant]\nItalian cuisine is delicious!\n[assistant:end]',
document_id: 'embed-shape-test',
});
const response = await client.recall({ query: 'What food do I like?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
}, 60_000);
it('should handle full end-to-end workflow via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Step 1: Retain
const retainResp = await client.retain({
content:
'[role: user]\nI am learning Rust programming.\n[user:end]\n\n' +
'[role: assistant]\nRust is a powerful systems language!\n[assistant:end]',
document_id: `embed-workflow-${Date.now()}`,
metadata: { channel_type: 'telegram', sender_id: '999' },
});
expect(retainResp).toBeDefined();
// Step 2: Recall
const recallResp = await client.recall({
query: 'What am I learning?',
max_tokens: 1024,
});
expect(recallResp).toBeDefined();
expect(Array.isArray(recallResp.results)).toBe(true);
}, 60_000);
});
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
testTimeout: 120_000,
hookTimeout: 120_000,
reporters: ['verbose'],
},
});
+3 -1
View File
@@ -366,6 +366,7 @@ else
[ -f "integration_test.go" ] && cp integration_test.go "$TEMP_DIR/"
[ -f "null_test.go" ] && cp null_test.go "$TEMP_DIR/"
[ -f "trace_test.go" ] && cp trace_test.go "$TEMP_DIR/"
[ -f "hindsight_client.go" ] && cp hindsight_client.go "$TEMP_DIR/"
# Remove old generated files
echo "Removing old generated code..."
@@ -381,7 +382,7 @@ else
-o . \
--package-name hindsight \
--git-user-id vectorize-io \
--git-repo-id hindsight-client-go \
--git-repo-id hindsight/hindsight-clients/go \
--global-property apiDocs=false,apiTests=false,modelDocs=false,modelTests=false
# Remove OpenAPI Generator boilerplate files
@@ -394,6 +395,7 @@ else
[ -f "$TEMP_DIR/integration_test.go" ] && mv "$TEMP_DIR/integration_test.go" .
[ -f "$TEMP_DIR/null_test.go" ] && mv "$TEMP_DIR/null_test.go" .
[ -f "$TEMP_DIR/trace_test.go" ] && mv "$TEMP_DIR/trace_test.go" .
[ -f "$TEMP_DIR/hindsight_client.go" ] && mv "$TEMP_DIR/hindsight_client.go" .
rm -rf "$TEMP_DIR"
# Fix known generator issue: api_files.go uses os.File but generator omits "os" import