Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2039581d8 | ||
|
|
dd14454e38 | ||
|
|
6fb8c0570f | ||
|
|
587939f337 |
@@ -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
|
||||
|
||||
@@ -726,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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 489 KiB |
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -237,20 +237,7 @@ export class HindsightClient {
|
||||
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const response = await res.json() as { results?: any[] };
|
||||
const results = response.results || [];
|
||||
|
||||
return {
|
||||
results: results.map((r: any) => ({
|
||||
content: r.text || r.content || '',
|
||||
score: r.score ?? 1.0,
|
||||
metadata: {
|
||||
document_id: r.document_id,
|
||||
chunk_id: r.chunk_id,
|
||||
...r.metadata,
|
||||
},
|
||||
})),
|
||||
};
|
||||
return res.json() as Promise<RecallResponse>;
|
||||
}
|
||||
|
||||
private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
|
||||
@@ -265,21 +252,7 @@ export class HindsightClient {
|
||||
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}`, { 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?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,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.
|
||||
@@ -671,44 +732,11 @@ export default function (api: MoltbotPluginAPI) {
|
||||
|
||||
// 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.
|
||||
let recallQuery = event.rawMessage;
|
||||
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
|
||||
// Fall back to prompt but strip envelope formatting
|
||||
recallQuery = event.prompt;
|
||||
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
let prompt = recallQuery.trim();
|
||||
if (prompt.length < 5) {
|
||||
return; // Skip very short messages after extraction
|
||||
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
|
||||
if (!extracted) {
|
||||
return;
|
||||
}
|
||||
let prompt = extracted;
|
||||
|
||||
// Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
|
||||
const MAX_RECALL_QUERY_CHARS = 800;
|
||||
@@ -758,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}
|
||||
@@ -835,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]`;
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user