Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2039581d8 | ||
|
|
dd14454e38 | ||
|
|
6fb8c0570f | ||
|
|
587939f337 | ||
|
|
c461013047 | ||
|
|
7c99feb018 | ||
|
|
d06a0259cc | ||
|
|
be8728b313 | ||
|
|
917893aac7 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
module github.com/vectorize-io/hindsight-client-go
|
||||
module github.com/vectorize-io/hindsight/hindsight-clients/go
|
||||
|
||||
go 1.18
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 (<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 (<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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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]
|
||||
@@ -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
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+1318
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user