Compare commits

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

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

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

* docs: add NEMOCLAW.md setup guide

* feat(nemoclaw): add README, docs page, and release pipeline
2026-03-20 14:59:29 +01:00
Ben dd14454e38 docs: NemoClaw persistent memory guide and blog post (#621)
* docs(openclaw): add NemoClaw blog
2026-03-19 17:36:01 -04:00
Nicolò Boschi 6fb8c0570f test(openclaw): export stripMemoryTags/extractRecallQuery and add hook integration tests
- Extract stripMemoryTags and extractRecallQuery as exported pure functions
  from index.ts so hooks share one implementation and tests cover the real code
- Update before_agent_start to call extractRecallQuery; update agent_end to
  call stripMemoryTags instead of duplicating the regex inline
- Rewrite index.test.ts to import the real functions (no more local duplicate)
  and add 11 tests for extractRecallQuery covering all envelope-stripping cases
- Add tests/hooks.integration.test.ts: loads the plugin via mock MoltbotPluginAPI
  in HTTP mode, spies on client.recall/retain, and exercises all hook behaviours:
  excluded providers, short messages, memory injection format, tag stripping,
  transcript formatting, array content blocks, metadata, document_id derivation
2026-02-18 13:50:37 +01:00
Nicolò Boschi 587939f337 fix: improve openclaw test coverage 2026-02-18 13:33:15 +01:00
29 changed files with 4512 additions and 136 deletions
+58 -1
View File
@@ -188,6 +188,55 @@ jobs:
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-nemoclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/nemoclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/nemoclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/nemoclaw
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/nemoclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: nemoclaw-integration
path: hindsight-integrations/nemoclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
@@ -487,7 +536,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-nemoclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -516,6 +565,12 @@ jobs:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download NemoClaw Integration
uses: actions/download-artifact@v4
with:
name: nemoclaw-integration
path: ./artifacts/nemoclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
@@ -565,6 +620,8 @@ jobs:
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# NemoClaw Integration
cp artifacts/nemoclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Control Plane
+101
View File
@@ -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

+196
View File
@@ -0,0 +1,196 @@
# Using hindsight-openclaw with NemoClaw
This guide covers running the `hindsight-openclaw` plugin inside a [NemoClaw](https://nemoclaw.ai) sandbox. NemoClaw runs OpenClaw inside an OpenShell sandbox, so the plugin's outbound calls to `api.hindsight.vectorize.io` must be explicitly allowed in the sandbox's network egress policy.
## Prerequisites
- NemoClaw installed and a sandbox created (`nemoclaw onboard`)
- OpenClaw installed (`brew install openclaw` or equivalent)
- A Hindsight API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
- The plugin source built (`npm run build` in this directory)
## Step 1: Create a Hindsight memory bank
Create the bank the plugin will write to. The bank ID follows the pattern `{bankIdPrefix}-openclaw` when `dynamicBankId` is false:
```bash
curl -X PUT "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"mission": "Memory bank for my NemoClaw sandbox."}'
```
## Step 2: Install the plugin
Install the plugin as a copy (not a symlink) so the OpenClaw LaunchAgent can access it:
```bash
# Build first if you haven't already
npm run build
# Install (copy, not link — required for LaunchAgent access)
openclaw plugins install /path/to/hindsight-integrations/openclaw
```
Alternatively, install from npm:
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
## Step 3: Configure the plugin
Add the plugin config to `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-hindsight-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
**Config notes:**
| Field | Value | Why |
|-------|-------|-----|
| `hindsightApiUrl` + `hindsightApiToken` | External API URL + key | Skips the local daemon; no `uvx`/`uv` required inside the sandbox |
| `llmProvider: "claude-code"` | `"claude-code"` | Satisfies LLM detection without a separate API key — Claude Code is available in the sandbox via the `claude_code` policy |
| `dynamicBankId: false` | `false` | All conversations write to one bank; easier to verify during testing |
| `bankIdPrefix` | e.g. `"my-sandbox"` | Results in bank ID `my-sandbox-openclaw` |
> **Note:** The gateway log will say `Dynamic bank IDs disabled - using static bank: openclaw` — this is a misleading log message. The actual bank ID used at runtime correctly applies the prefix (e.g. `my-sandbox-openclaw`). You can verify by watching for `[Hindsight] Default bank: my-sandbox-openclaw` in the logs after full initialization.
## Step 4: Add the Hindsight network policy to the sandbox
The sandbox blocks all outbound traffic by default. You need to add `api.hindsight.vectorize.io` to the egress policy.
Get the current full policy by running `openshell sandbox get <name>` and save it to a YAML file, then add the `hindsight` block under `network_policies`:
```yaml
network_policies:
# ... your existing policies ...
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
Apply it:
```bash
openshell policy set <sandbox-name> --policy /path/to/full-policy.yaml --wait
```
> **Important:** `openshell policy set` replaces the entire policy, not just patches it. Make sure your YAML includes all existing network policies or they will be removed.
Verify the policy loaded:
```bash
openshell policy get <sandbox-name>
# Should show: Status: Loaded, and version incremented
```
## Step 5: Restart the OpenClaw gateway
```bash
openclaw gateway restart
```
Watch the logs to confirm the plugin loaded and the API is reachable:
```bash
# Should see:
# [Hindsight] Plugin loaded successfully
# [Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
# [Hindsight] External API health: {"status":"healthy","database":"connected"}
# [Hindsight] Default bank: my-sandbox-openclaw
# [Hindsight] ✓ Ready (external API mode)
grep Hindsight ~/.openclaw/logs/gateway.log | tail -20
```
## Step 6: Test
Send a message to the agent:
```bash
openclaw agent --agent main --session-id test-1 \
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
```
Verify memory was retained (check logs):
```bash
grep "Retained\|agent_end" ~/.openclaw/logs/gateway.log | tail -5
# Should see: [Hindsight] Retained N messages to bank my-sandbox-openclaw for session ...
```
Test recall in a new session:
```bash
openclaw agent --agent main --session-id test-2 \
-m "What do you remember about me?"
# Should recall your name and preferences from the previous session
```
You can also verify directly against the API:
```bash
curl -s -X POST "https://api.hindsight.vectorize.io/v1/default/banks/my-sandbox-openclaw/memories/recall" \
-H "Authorization: Bearer <your-hindsight-api-key>" \
-H "Content-Type: application/json" \
-d '{"query": "what do you know about the user", "max_tokens": 512}'
```
## Troubleshooting
**Plugin fails to load with `EPERM: operation not permitted, scandir`**
You used `--link` when installing. The OpenClaw LaunchAgent runs under a restricted macOS security context and cannot access `~/Documents` or other user directories by symlink. Reinstall without `--link`:
```bash
openclaw plugins uninstall hindsight-openclaw
openclaw plugins install /path/to/hindsight-integrations/openclaw # no --link
```
**`[Hindsight] Failed to retain memory (HTTP 403)`**
The sandbox network policy is blocking the outbound call. Check that:
1. The `hindsight` network policy block is present in your policy YAML
2. The policy was applied and shows `Status: Loaded` (`openshell policy get <name>`)
3. The `binaries` list includes `/usr/local/bin/openclaw`
**Gateway restart times out but then recovers**
This is normal on first restart after installing a plugin — the LaunchAgent takes a moment to reload. The gateway is healthy if `openclaw gateway status` shows `RPC probe: ok`.
**`openclaw agent` fails with `Pass --to, --session-id, or --agent`**
You need to specify a session. Use `--agent main` to use the default agent, or `--session-id <any-string>` to create a named session.
+60
View File
@@ -0,0 +1,60 @@
# hindsight-nemoclaw
One-command setup for [Hindsight](https://hindsight.vectorize.io) persistent memory on [NemoClaw](https://nemoclaw.ai) sandboxes.
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with strict network egress policies. This package automates the full setup: installing the `hindsight-openclaw` plugin, configuring external API mode, merging the Hindsight egress rule into your sandbox policy, and restarting the gateway.
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
## Documentation
Full setup guide, pitfalls, and troubleshooting:
**[NemoClaw Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/nemoclaw)**
Or see [NEMOCLAW.md](./NEMOCLAW.md) in this directory for a step-by-step walkthrough.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
## What It Does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads current sandbox policy, merges Hindsight egress rule, re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
## Links
- [Hindsight Documentation](https://vectorize.io/hindsight)
- [NemoClaw](https://nemoclaw.ai)
- [OpenClaw](https://openclaw.ai)
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
## License
MIT
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
{
"name": "@vectorize-io/hindsight-nemoclaw",
"version": "0.1.0",
"description": "Setup CLI for hindsight-openclaw on NemoClaw sandboxes — installs the plugin, configures external API mode, and applies the OpenShell network policy",
"type": "module",
"main": "dist/cli.js",
"bin": {
"hindsight-nemoclaw": "dist/cli.js"
},
"keywords": [
"nemoclaw",
"openclaw",
"memory",
"ai",
"agent",
"hindsight",
"openshell",
"nvidia"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/nemoclaw"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsc && node -e \"const f='dist/cli.js',s=require('fs');s.writeFileSync(f,'#!/usr/bin/env node\\n'+s.readFileSync(f,'utf8'));s.chmodSync(f,0o755)\"",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
}
}
@@ -0,0 +1,84 @@
import { runSetup } from './setup.js';
import type { CliArgs } from './types.js';
function usage(): void {
process.stdout.write(`
hindsight-nemoclaw — Setup CLI for Hindsight memory on NemoClaw sandboxes
Usage:
hindsight-nemoclaw setup [options]
Required options:
--sandbox <name> NemoClaw sandbox name (e.g. my-assistant)
--api-url <url> Hindsight Cloud API URL (https://api.hindsight.vectorize.io)
--api-token <token> Hindsight API key from https://ui.hindsight.vectorize.io
--bank-prefix <prefix> Bank ID prefix (memories go to <prefix>-openclaw)
Optional options:
--skip-policy Skip the openshell policy update
--skip-plugin-install Skip openclaw plugins install
--dry-run Print what would be changed without executing
--help Show this help
Example:
hindsight-nemoclaw setup \\
--sandbox my-assistant \\
--api-url https://api.hindsight.vectorize.io \\
--api-token hsk_abc123 \\
--bank-prefix my-sandbox
`);
}
function parseArgs(argv: string[]): CliArgs | null {
const args = argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
usage();
return null;
}
if (args[0] !== 'setup') {
process.stderr.write(`Unknown command: ${args[0]}\nRun with --help for usage.\n`);
process.exit(1);
}
const get = (flag: string): string | undefined => {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
};
const sandbox = get('--sandbox');
const apiUrl = get('--api-url');
const apiToken = get('--api-token');
const bankPrefix = get('--bank-prefix');
const missing: string[] = [];
if (!sandbox) missing.push('--sandbox');
if (!apiUrl) missing.push('--api-url');
if (!apiToken) missing.push('--api-token');
if (!bankPrefix) missing.push('--bank-prefix');
if (missing.length > 0) {
process.stderr.write(`Missing required options: ${missing.join(', ')}\nRun with --help for usage.\n`);
process.exit(1);
}
return {
sandbox: sandbox!,
apiUrl: apiUrl!,
apiToken: apiToken!,
bankPrefix: bankPrefix!,
skipPolicy: args.includes('--skip-policy'),
skipPluginInstall: args.includes('--skip-plugin-install'),
dryRun: args.includes('--dry-run'),
};
}
const args = parseArgs(process.argv);
if (args) {
runSetup(args).catch(err => {
process.stderr.write(`\nError: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
}
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { mergePluginConfig } from './openclaw-config.js';
import type { OpenClawConfig, HindsightPluginConfig } from './openclaw-config.js';
const PLUGIN_CONFIG: HindsightPluginConfig = {
hindsightApiUrl: 'https://api.hindsight.vectorize.io',
hindsightApiToken: 'hsk_test123',
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: 'my-sandbox',
};
const BASE_CONFIG: OpenClawConfig = {
meta: { lastTouchedVersion: '2026.3.2' },
gateway: { port: 18789, mode: 'local' },
agents: {
defaults: { model: { primary: 'openai/gpt-5' } },
},
plugins: {
slots: { memory: 'memory-core' },
entries: {
'memory-core': { enabled: false },
},
},
};
describe('mergePluginConfig', () => {
it('sets hindsight-openclaw as the memory slot', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
});
it('enables the hindsight-openclaw entry', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('writes the full plugin config', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
expect(config?.hindsightApiToken).toBe('hsk_test123');
expect(config?.llmProvider).toBe('claude-code');
expect(config?.dynamicBankId).toBe(false);
expect(config?.bankIdPrefix).toBe('my-sandbox');
});
it('preserves existing top-level config fields', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.gateway).toEqual({ port: 18789, mode: 'local' });
expect(result.agents).toBeDefined();
});
it('preserves existing plugin entries', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(result.plugins?.entries?.['memory-core']?.enabled).toBe(false);
});
it('merges into existing hindsight-openclaw entry without overwriting other fields', () => {
const configWithExisting: OpenClawConfig = {
...BASE_CONFIG,
plugins: {
...BASE_CONFIG.plugins,
entries: {
...BASE_CONFIG.plugins?.entries,
'hindsight-openclaw': {
enabled: true,
config: { embedPackagePath: '/some/local/path' },
},
},
},
};
const result = mergePluginConfig(configWithExisting, PLUGIN_CONFIG);
const config = result.plugins?.entries?.['hindsight-openclaw']?.config;
// New fields written
expect(config?.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
// Existing custom field preserved
expect(config?.embedPackagePath).toBe('/some/local/path');
});
it('handles missing plugins section gracefully', () => {
const minimal: OpenClawConfig = { gateway: { port: 18789 } };
const result = mergePluginConfig(minimal, PLUGIN_CONFIG);
expect(result.plugins?.slots?.memory).toBe('hindsight-openclaw');
expect(result.plugins?.entries?.['hindsight-openclaw']?.enabled).toBe(true);
});
it('does not mutate the original config', () => {
const original = JSON.parse(JSON.stringify(BASE_CONFIG)) as OpenClawConfig;
mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
expect(JSON.stringify(BASE_CONFIG)).toBe(JSON.stringify(original));
});
it('records install metadata', () => {
const result = mergePluginConfig(BASE_CONFIG, PLUGIN_CONFIG);
const install = result.plugins?.installs?.['hindsight-openclaw'] as Record<string, unknown>;
expect(install?.source).toBe('npm');
expect(install?.version).toBe('latest');
expect(typeof install?.installedAt).toBe('string');
});
});
@@ -0,0 +1,106 @@
import { readFile, writeFile, rename } from 'fs/promises';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
const CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
export interface HindsightPluginConfig {
hindsightApiUrl: string;
hindsightApiToken: string;
llmProvider: string;
dynamicBankId: boolean;
bankIdPrefix: string;
}
export interface OpenClawConfig {
plugins?: {
slots?: Record<string, string>;
entries?: Record<string, { enabled: boolean; config?: Record<string, unknown> }>;
installs?: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
export async function readOpenClawConfig(configPath = CONFIG_PATH): Promise<OpenClawConfig> {
let raw: string;
try {
raw = await readFile(configPath, 'utf8');
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new Error(
`OpenClaw config not found at ${configPath}.\n` +
`Run \`openclaw\` once to initialize it, then re-run setup.`
);
}
throw err;
}
return JSON.parse(raw) as OpenClawConfig;
}
export function mergePluginConfig(
config: OpenClawConfig,
pluginConfig: HindsightPluginConfig
): OpenClawConfig {
const plugins = config.plugins ?? {};
const entries = plugins.entries ?? {};
const existing = entries['hindsight-openclaw'] ?? { enabled: true };
return {
...config,
plugins: {
...plugins,
slots: {
...(plugins.slots ?? {}),
memory: 'hindsight-openclaw',
},
entries: {
...entries,
'hindsight-openclaw': {
...existing,
enabled: true,
config: {
...(existing.config ?? {}),
hindsightApiUrl: pluginConfig.hindsightApiUrl,
hindsightApiToken: pluginConfig.hindsightApiToken,
llmProvider: pluginConfig.llmProvider,
dynamicBankId: pluginConfig.dynamicBankId,
bankIdPrefix: pluginConfig.bankIdPrefix,
},
},
},
installs: {
...(plugins.installs ?? {}),
'hindsight-openclaw': {
source: 'npm',
version: 'latest',
installedAt: new Date().toISOString(),
},
},
},
};
}
export async function writeOpenClawConfig(
config: OpenClawConfig,
configPath = CONFIG_PATH
): Promise<void> {
const contents = JSON.stringify(config, null, 2) + '\n';
const tmp = `${configPath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, contents, 'utf8');
await rename(tmp, configPath);
}
export async function applyPluginConfig(
pluginConfig: HindsightPluginConfig,
configPath = CONFIG_PATH
): Promise<void> {
const current = await readOpenClawConfig(configPath);
const updated = mergePluginConfig(current, pluginConfig);
await writeOpenClawConfig(updated, configPath);
}
export { CONFIG_PATH };
export { dirname };
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { stripAnsi, extractPolicyYaml, parseSandboxPolicy } from './policy-reader.js';
import { serializePolicy } from './policy-writer.js';
// Fixture: actual output of `openshell sandbox get my-assistant`
// (ANSI codes represented as escape sequences)
const FIXTURE_RAW = `\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m
\x1b[2mId:\x1b[0m 61c993f1-010f-4eca-a1ac-d6ddec9d604a
\x1b[2mName:\x1b[0m my-assistant
\x1b[2mNamespace:\x1b[0m openshell
\x1b[2mPhase:\x1b[0m Ready
\x1b[1m\x1b[36mPolicy:\x1b[39m\x1b[0m
\x1b[2mversion\x1b[0m\x1b[2m:\x1b[0m 1
\x1b[2mfilesystem_policy\x1b[0m\x1b[2m:\x1b[0m
\x1b[2minclude_workdir\x1b[0m\x1b[2m:\x1b[0m true
\x1b[2mread_only\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/usr
\x1b[2m- \x1b[0m/lib
\x1b[2mread_write\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0m/sandbox
\x1b[2m- \x1b[0m/tmp
\x1b[2mnetwork_policies\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mclaude_code\x1b[0m\x1b[2m:\x1b[0m
\x1b[2mname\x1b[0m\x1b[2m:\x1b[0m claude_code
\x1b[2mendpoints\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mhost: api.anthropic.com
\x1b[2mport\x1b[0m\x1b[2m:\x1b[0m 443
\x1b[2mrules\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mallow:
\x1b[2mmethod\x1b[0m\x1b[2m:\x1b[0m '*'
\x1b[2mpath\x1b[0m\x1b[2m:\x1b[0m /**
\x1b[2mbinaries\x1b[0m\x1b[2m:\x1b[0m
\x1b[2m- \x1b[0mpath: /usr/local/bin/claude
`;
describe('stripAnsi', () => {
it('removes ANSI escape codes', () => {
expect(stripAnsi('\x1b[1m\x1b[36mHello\x1b[39m\x1b[0m')).toBe('Hello');
});
it('leaves plain strings unchanged', () => {
expect(stripAnsi('version: 1')).toBe('version: 1');
});
it('handles strings with no ANSI codes', () => {
expect(stripAnsi(' - /usr')).toBe(' - /usr');
});
});
describe('extractPolicyYaml', () => {
it('extracts the Policy: section and dedents by 2 spaces', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).toContain('version: 1');
expect(result).toContain('filesystem_policy:');
expect(result).toContain('network_policies:');
});
it('does not include the Sandbox: section', () => {
const result = extractPolicyYaml(FIXTURE_RAW);
expect(result).not.toContain('Sandbox:');
expect(result).not.toContain('my-assistant');
});
it('throws if Policy: section is missing', () => {
expect(() => extractPolicyYaml('no policy here')).toThrow('Could not find "Policy:"');
});
});
describe('parseSandboxPolicy', () => {
it('parses version field', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.version).toBe(1);
});
it('parses filesystem_policy', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.filesystem_policy?.include_workdir).toBe(true);
expect(policy.filesystem_policy?.read_only).toContain('/usr');
});
it('parses network_policies', () => {
const policy = parseSandboxPolicy(FIXTURE_RAW);
expect(policy.network_policies).toBeDefined();
expect(policy.network_policies?.claude_code).toBeDefined();
expect(policy.network_policies?.claude_code?.name).toBe('claude_code');
});
it('is idempotent — parse → serialize → parse yields same structure', () => {
const policy1 = parseSandboxPolicy(FIXTURE_RAW);
const yamlStr = serializePolicy(policy1);
// Re-wrap in a Policy: header to match the expected format
const wrapped = 'Policy:\n' + yamlStr.split('\n').map((l: string) => ` ${l}`).join('\n');
const policy2 = parseSandboxPolicy(wrapped);
expect(policy2.version).toBe(policy1.version);
expect(Object.keys(policy2.network_policies ?? {})).toEqual(
Object.keys(policy1.network_policies ?? {})
);
});
});
@@ -0,0 +1,88 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
const execFileAsync = promisify(execFile);
/** Strip ANSI escape codes from a string */
export function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*m/g, '');
}
/**
* Extract and dedent the policy section from `openshell sandbox get` output.
* The output looks like:
*
* Sandbox:
* Id: ...
* Name: ...
*
* Policy:
* version: 1
* filesystem_policy:
* ...
*
* We need to extract everything after `Policy:` and dedent by 2 spaces.
*/
export function extractPolicyYaml(raw: string): string {
const stripped = stripAnsi(raw);
const lines = stripped.split('\n');
const policyHeaderIdx = lines.findIndex(l => l.trimEnd() === 'Policy:');
if (policyHeaderIdx === -1) {
throw new Error('Could not find "Policy:" section in `openshell sandbox get` output');
}
const policyLines = lines.slice(policyHeaderIdx + 1);
// Dedent by 2 spaces (the policy block is indented under `Policy:`)
const dedented = policyLines.map(l => {
if (l.startsWith(' ')) return l.slice(2);
return l;
});
// Drop trailing empty lines
while (dedented.length > 0 && dedented[dedented.length - 1].trim() === '') {
dedented.pop();
}
return dedented.join('\n');
}
/**
* Parse `openshell sandbox get <name>` output into a SandboxPolicy object.
* Throws a descriptive error if parsing fails.
*/
export function parseSandboxPolicy(rawOutput: string): SandboxPolicy {
const policyYaml = extractPolicyYaml(rawOutput);
try {
const parsed = yaml.load(policyYaml);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Parsed policy is not an object');
}
return parsed as SandboxPolicy;
} catch (err) {
throw new Error(
`Failed to parse sandbox policy YAML.\n` +
`This may mean the openshell output format has changed.\n` +
`Apply the Hindsight policy manually using the instructions in NEMOCLAW.md.\n` +
`Parse error: ${err}`
);
}
}
/** Run `openshell sandbox get <sandbox>` and return parsed policy */
export async function readSandboxPolicy(sandboxName: string): Promise<SandboxPolicy> {
let stdout: string;
try {
const result = await execFileAsync('openshell', ['sandbox', 'get', sandboxName]);
stdout = result.stdout;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to run \`openshell sandbox get ${sandboxName}\`: ${msg}`);
}
return parseSandboxPolicy(stdout);
}
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { parseSandboxPolicy } from './policy-reader.js';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const BASE_POLICY: SandboxPolicy = {
version: 1,
filesystem_policy: {
include_workdir: true,
read_only: ['/usr', '/lib'],
read_write: ['/sandbox', '/tmp'],
},
network_policies: {
claude_code: {
name: 'claude_code',
endpoints: [
{
host: 'api.anthropic.com',
port: 443,
rules: [{ allow: { method: '*', path: '/**' } }],
},
],
binaries: [{ path: '/usr/local/bin/claude' }],
},
},
};
describe('hasHindsightPolicy', () => {
it('returns false when no hindsight policy exists', () => {
expect(hasHindsightPolicy(BASE_POLICY)).toBe(false);
});
it('returns false when network_policies is undefined', () => {
expect(hasHindsightPolicy({ version: 1 })).toBe(false);
});
it('returns true when hindsight policy is present', () => {
const withHindsight = mergeHindsightPolicy(BASE_POLICY);
expect(hasHindsightPolicy(withHindsight)).toBe(true);
});
});
describe('mergeHindsightPolicy', () => {
it('adds the hindsight network policy block', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.hindsight).toBeDefined();
expect(result.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
});
it('preserves all existing network policies', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
expect(result.network_policies?.claude_code).toBeDefined();
expect(result.network_policies?.claude_code?.name).toBe('claude_code');
});
it('sets the correct binary path', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const binaries = result.network_policies?.hindsight?.binaries ?? [];
expect(binaries.some(b => b.path === OPENCLAW_BINARY)).toBe(true);
});
it('includes GET, POST, and PUT rules', () => {
const result = mergeHindsightPolicy(BASE_POLICY);
const rules = result.network_policies?.hindsight?.endpoints[0].rules ?? [];
const methods = rules.map(r => r.allow.method);
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
});
it('is idempotent — merging twice yields the same result', () => {
const once = mergeHindsightPolicy(BASE_POLICY);
const twice = mergeHindsightPolicy(once);
expect(JSON.stringify(twice.network_policies?.hindsight)).toBe(
JSON.stringify(once.network_policies?.hindsight)
);
});
it('does not mutate the original policy', () => {
const original = JSON.parse(JSON.stringify(BASE_POLICY)) as SandboxPolicy;
mergeHindsightPolicy(BASE_POLICY);
expect(BASE_POLICY.network_policies?.hindsight).toBeUndefined();
expect(JSON.stringify(BASE_POLICY)).toBe(JSON.stringify(original));
});
});
describe('serializePolicy', () => {
it('produces valid YAML that round-trips through parseSandboxPolicy', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yamlStr = serializePolicy(merged);
// Wrap in Policy: header as parseSandboxPolicy expects
const wrapped = 'Policy:\n' + yamlStr.split('\n').map(l => ` ${l}`).join('\n');
const reparsed = parseSandboxPolicy(wrapped);
expect(reparsed.version).toBe(merged.version);
expect(reparsed.network_policies?.hindsight?.endpoints[0].host).toBe(HINDSIGHT_HOST);
expect(reparsed.network_policies?.claude_code).toBeDefined();
});
it('includes all network policies in output', () => {
const merged = mergeHindsightPolicy(BASE_POLICY);
const yaml = serializePolicy(merged);
expect(yaml).toContain('claude_code:');
expect(yaml).toContain('hindsight:');
expect(yaml).toContain(HINDSIGHT_HOST);
});
});
@@ -0,0 +1,59 @@
import yaml from 'js-yaml';
import type { SandboxPolicy } from './types.js';
import { HINDSIGHT_POLICY_NAME, HINDSIGHT_HOST, OPENCLAW_BINARY } from './types.js';
const HINDSIGHT_NETWORK_POLICY = {
name: HINDSIGHT_POLICY_NAME,
endpoints: [
{
host: HINDSIGHT_HOST,
port: 443,
protocol: 'rest',
tls: 'terminate',
enforcement: 'enforce',
rules: [
{ allow: { method: 'GET', path: '/**' } },
{ allow: { method: 'POST', path: '/**' } },
{ allow: { method: 'PUT', path: '/**' } },
],
},
],
binaries: [{ path: OPENCLAW_BINARY }],
};
/**
* Returns true if the policy already has a correct Hindsight network policy entry.
*/
export function hasHindsightPolicy(policy: SandboxPolicy): boolean {
const np = policy.network_policies?.[HINDSIGHT_POLICY_NAME];
if (!np) return false;
return np.endpoints?.some(e => e.host === HINDSIGHT_HOST) ?? false;
}
/**
* Merge the Hindsight network policy block into a SandboxPolicy.
* Idempotent — if the block already exists and is correct, returns policy unchanged.
*/
export function mergeHindsightPolicy(policy: SandboxPolicy): SandboxPolicy {
const updated: SandboxPolicy = {
...policy,
network_policies: {
...(policy.network_policies ?? {}),
[HINDSIGHT_POLICY_NAME]: HINDSIGHT_NETWORK_POLICY,
},
};
return updated;
}
/**
* Serialize a SandboxPolicy to a YAML string suitable for `openshell policy set`.
*/
export function serializePolicy(policy: SandboxPolicy): string {
return yaml.dump(policy, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
}
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CliArgs } from './types.js';
// Mock all external I/O before importing setup
vi.mock('child_process', () => ({
execFile: vi.fn(),
}));
vi.mock('./policy-reader.js', () => ({
readSandboxPolicy: vi.fn(),
}));
vi.mock('./policy-writer.js', () => ({
hasHindsightPolicy: vi.fn(),
mergeHindsightPolicy: vi.fn(),
serializePolicy: vi.fn(),
}));
vi.mock('./openclaw-config.js', () => ({
applyPluginConfig: vi.fn(),
}));
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
rm: vi.fn(),
}));
const BASE_ARGS: CliArgs = {
sandbox: 'my-assistant',
apiUrl: 'https://api.hindsight.vectorize.io',
apiToken: 'hsk_test123',
bankPrefix: 'my-sandbox',
skipPolicy: false,
skipPluginInstall: false,
dryRun: false,
};
describe('runSetup', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { execFile } = await import('child_process');
const execFileMock = vi.mocked(execFile);
// Default: all shell commands succeed
execFileMock.mockImplementation((_cmd, _args, callback?: unknown) => {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { readSandboxPolicy } = await import('./policy-reader.js');
vi.mocked(readSandboxPolicy).mockResolvedValue({
version: 1,
network_policies: { claude_code: { name: 'claude_code', endpoints: [] } },
});
const { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(false);
vi.mocked(mergeHindsightPolicy).mockImplementation(p => ({ ...p, network_policies: { ...p.network_policies, hindsight: { name: 'hindsight', endpoints: [] } } }));
vi.mocked(serializePolicy).mockReturnValue('version: 1\n');
const { applyPluginConfig } = await import('./openclaw-config.js');
vi.mocked(applyPluginConfig).mockResolvedValue(undefined);
});
it('runs all steps in order for a clean install', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('which openshell'))).toBe(true);
expect(calls.some(c => c.includes('which openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openclaw plugins install @vectorize-io/hindsight-openclaw'))).toBe(true);
expect(calls.some(c => c.includes('openshell policy set my-assistant'))).toBe(true);
expect(calls.some(c => c.includes('openclaw gateway restart'))).toBe(true);
});
it('skips plugin install when --skip-plugin-install is set', async () => {
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, skipPluginInstall: true });
expect(calls.some(c => c.includes('plugins install'))).toBe(false);
});
it('skips policy update when --skip-policy is set', async () => {
const { runSetup } = await import('./setup.js');
const { readSandboxPolicy } = await import('./policy-reader.js');
await runSetup({ ...BASE_ARGS, skipPolicy: true });
expect(vi.mocked(readSandboxPolicy)).not.toHaveBeenCalled();
});
it('skips policy set when Hindsight policy already exists', async () => {
const { hasHindsightPolicy } = await import('./policy-writer.js');
vi.mocked(hasHindsightPolicy).mockReturnValue(true);
const { execFile } = await import('child_process');
const calls: string[] = [];
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
calls.push(`${cmd} ${(args as string[]).join(' ')}`);
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await runSetup(BASE_ARGS);
expect(calls.some(c => c.includes('openshell policy set'))).toBe(false);
});
it('does not execute any shell commands in dry-run mode', async () => {
const { execFile } = await import('child_process');
const { applyPluginConfig } = await import('./openclaw-config.js');
const { writeFile } = await import('fs/promises');
const { runSetup } = await import('./setup.js');
await runSetup({ ...BASE_ARGS, dryRun: true });
// which checks still run (preflight), but no actual commands
const execCalls = vi.mocked(execFile).mock.calls.map(c => `${c[0]} ${(c[1] as string[]).join(' ')}`);
expect(execCalls.some(c => c.includes('plugins install'))).toBe(false);
expect(execCalls.some(c => c.includes('policy set'))).toBe(false);
expect(execCalls.some(c => c.includes('gateway restart'))).toBe(false);
expect(vi.mocked(applyPluginConfig)).not.toHaveBeenCalled();
expect(vi.mocked(writeFile)).not.toHaveBeenCalled();
});
it('fails early if openshell is not on PATH', async () => {
const { execFile } = await import('child_process');
vi.mocked(execFile).mockImplementation((cmd, args, callback?: unknown) => {
if (cmd === 'which' && (args as string[])[0] === 'openshell') {
if (typeof callback === 'function') {
(callback as (err: Error) => void)(new Error('not found'));
}
} else {
if (typeof callback === 'function') {
(callback as (err: null, result: { stdout: string; stderr: string }) => void)(
null, { stdout: '', stderr: '' }
);
}
}
return {} as ReturnType<typeof execFile>;
});
const { runSetup } = await import('./setup.js');
await expect(runSetup(BASE_ARGS)).rejects.toThrow('openshell');
});
});
@@ -0,0 +1,148 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import { writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { randomBytes } from 'crypto';
import type { CliArgs } from './types.js';
import { readSandboxPolicy } from './policy-reader.js';
import { hasHindsightPolicy, mergeHindsightPolicy, serializePolicy } from './policy-writer.js';
import { applyPluginConfig } from './openclaw-config.js';
const execFileAsync = promisify(execFile);
function log(msg: string) {
process.stdout.write(`${msg}\n`);
}
function step(n: number, msg: string) {
log(`\n[${n}] ${msg}`);
}
async function which(bin: string): Promise<boolean> {
try {
await execFileAsync('which', [bin]);
return true;
} catch {
return false;
}
}
export async function runSetup(args: CliArgs): Promise<void> {
log('\nhindsight-nemoclaw setup');
log('─'.repeat(40));
// Step 0 — Preflight
step(0, 'Preflight checks...');
const [hasOpenshell, hasOpenclaw] = await Promise.all([which('openshell'), which('openclaw')]);
if (!hasOpenshell) {
throw new Error('`openshell` not found on PATH. Install it from https://openshell.ai');
}
if (!hasOpenclaw) {
throw new Error('`openclaw` not found on PATH. Install it from https://openclaw.ai');
}
log(' ✓ openshell found');
log(' ✓ openclaw found');
// Step 1 — Install hindsight-openclaw plugin
if (!args.skipPluginInstall) {
step(1, 'Installing @vectorize-io/hindsight-openclaw plugin...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw plugins install @vectorize-io/hindsight-openclaw');
} else {
const { stdout } = await execFileAsync('openclaw', [
'plugins', 'install', '@vectorize-io/hindsight-openclaw',
]);
log(stdout.trim() || ' ✓ Plugin installed');
}
} else {
step(1, 'Skipping plugin install (--skip-plugin-install)');
}
// Step 2 — Configure ~/.openclaw/openclaw.json
step(2, 'Configuring plugin in ~/.openclaw/openclaw.json...');
const pluginConfig = {
hindsightApiUrl: args.apiUrl,
hindsightApiToken: args.apiToken,
llmProvider: 'claude-code',
dynamicBankId: false,
bankIdPrefix: args.bankPrefix,
};
if (args.dryRun) {
log(` [dry-run] would write plugin config to ~/.openclaw/openclaw.json`);
log(` config: ${JSON.stringify(pluginConfig, null, 4).split('\n').join('\n ')}`);
} else {
await applyPluginConfig(pluginConfig);
log(` ✓ Plugin config written (bank: ${args.bankPrefix}-openclaw)`);
}
// Step 3 — Apply OpenShell network policy
if (!args.skipPolicy) {
step(3, `Applying Hindsight network policy to sandbox "${args.sandbox}"...`);
const currentPolicy = await readSandboxPolicy(args.sandbox);
if (hasHindsightPolicy(currentPolicy)) {
log(' ✓ Hindsight policy already present — skipping');
} else {
const updatedPolicy = mergeHindsightPolicy(currentPolicy);
const policyYaml = serializePolicy(updatedPolicy);
if (args.dryRun) {
log(' [dry-run] would apply policy:');
log(policyYaml.split('\n').map(l => ` ${l}`).join('\n'));
} else {
const tmpFile = join(tmpdir(), `hindsight-policy-${randomBytes(6).toString('hex')}.yaml`);
try {
await writeFile(tmpFile, policyYaml, 'utf8');
const { stdout } = await execFileAsync('openshell', [
'policy', 'set', args.sandbox, '--policy', tmpFile, '--wait',
]);
log(stdout.trim() || ` ✓ Policy applied to sandbox "${args.sandbox}"`);
} finally {
await rm(tmpFile, { force: true });
}
}
}
} else {
step(3, 'Skipping policy update (--skip-policy)');
log(' Add the following block to your sandbox network_policies manually:');
log('');
log(' hindsight:');
log(' name: hindsight');
log(' endpoints:');
log(' - host: api.hindsight.vectorize.io');
log(' port: 443');
log(' protocol: rest');
log(' tls: terminate');
log(' enforcement: enforce');
log(' rules:');
log(' - allow: { method: GET, path: /** }');
log(' - allow: { method: POST, path: /** }');
log(' - allow: { method: PUT, path: /** }');
log(' binaries:');
log(' - path: /usr/local/bin/openclaw');
}
// Step 4 — Restart gateway
step(4, 'Restarting OpenClaw gateway...');
if (args.dryRun) {
log(' [dry-run] would run: openclaw gateway restart');
} else {
await execFileAsync('openclaw', ['gateway', 'restart']);
log(' ✓ Gateway restarted');
}
log('\n' + '─'.repeat(40));
log('✓ Setup complete!\n');
log(` Bank ID: ${args.bankPrefix}-openclaw`);
log(` API URL: ${args.apiUrl}`);
log('');
log(' Watch gateway logs to confirm:');
log(' grep Hindsight ~/.openclaw/logs/gateway.log | tail -5');
log(' Expected: [Hindsight] ✓ Ready (external API mode)');
log('');
log(' Test memory retention:');
log(` openclaw agent --agent main --session-id test-1 -m "My name is Ben."`);
log(` openclaw agent --agent main --session-id test-2 -m "What do you remember about me?"`);
}
@@ -0,0 +1,63 @@
export interface CliArgs {
sandbox: string;
apiUrl: string;
apiToken: string;
bankPrefix: string;
skipPolicy: boolean;
skipPluginInstall: boolean;
dryRun: boolean;
}
export interface PolicyEndpointRule {
allow: {
method: string;
path: string;
};
}
export interface PolicyEndpoint {
host: string;
port: number;
protocol?: string;
tls?: string;
enforcement?: string;
access?: string;
rules?: PolicyEndpointRule[];
}
export interface PolicyBinary {
path: string;
}
export interface NetworkPolicy {
name: string;
endpoints: PolicyEndpoint[];
binaries?: PolicyBinary[];
}
export interface FilesystemPolicy {
include_workdir?: boolean;
read_only?: string[];
read_write?: string[];
}
export interface Landlock {
compatibility?: string;
}
export interface ProcessPolicy {
run_as_user?: string;
run_as_group?: string;
}
export interface SandboxPolicy {
version?: number;
filesystem_policy?: FilesystemPolicy;
landlock?: Landlock;
process?: ProcessPolicy;
network_policies?: Record<string, NetworkPolicy>;
}
export const HINDSIGHT_POLICY_NAME = 'hindsight';
export const HINDSIGHT_HOST = 'api.hindsight.vectorize.io';
export const OPENCLAW_BINARY = '/usr/local/bin/openclaw';
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
+3 -2
View File
@@ -34,8 +34,9 @@
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test": "vitest run src",
"test:watch": "vitest src",
"test:integration": "vitest run --config vitest.integration.config.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
+2 -29
View File
@@ -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 });
}
+115 -55
View File
@@ -1,83 +1,143 @@
import { describe, it, expect } from 'vitest';
import { stripMemoryTags, extractRecallQuery } from './index.js';
/**
* Unit tests for the memory feedback loop fix.
* Verifies that <hindsight_memories> and <relevant_memories> tags
* are stripped from content before RETAIN to prevent duplicates.
*/
describe('Memory Tag Stripping', () => {
/**
* Simulates the tag stripping logic from agent_end hook
*/
function stripMemoryTags(content: string): string {
// Strip plugin-injected memory tags to prevent feedback loop
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
// ---------------------------------------------------------------------------
// stripMemoryTags
// ---------------------------------------------------------------------------
it('should strip simple hindsight_memories tags', () => {
const input = 'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
const expected = 'User: Hello\n\nAssistant: How can I help?';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
describe('stripMemoryTags', () => {
it('strips simple hindsight_memories tags', () => {
const input =
'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
expect(stripMemoryTags(input)).toBe('User: Hello\n\nAssistant: How can I help?');
});
it('should strip relevant_memories tags', () => {
it('strips relevant_memories tags', () => {
const input = 'Before\n<relevant_memories>\nSome data\n</relevant_memories>\nAfter';
const expected = 'Before\n\nAfter';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(stripMemoryTags(input)).toBe('Before\n\nAfter');
});
it('should strip multiple hindsight_memories blocks', () => {
const input = 'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
const expected = 'Start\n\nMiddle\n\nEnd';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
it('strips multiple hindsight_memories blocks', () => {
const input =
'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
expect(stripMemoryTags(input)).toBe('Start\n\nMiddle\n\nEnd');
});
it('should handle multiline memory blocks with JSON', () => {
const input = 'User: What is the weather?\n<hindsight_memories>\nRelevant memories:\n{\n "memory": "User likes sunny weather"\n}\n</hindsight_memories>\nAssistant: Let me check';
const expected = 'User: What is the weather?\n\nAssistant: Let me check';
it('handles multiline memory blocks with JSON', () => {
const input =
'User: What is the weather?\n<hindsight_memories>\n[\n {"memory": "User likes sunny weather"}\n]\n</hindsight_memories>\nAssistant: Let me check';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(result).toBe('User: What is the weather?\n\nAssistant: Let me check');
});
it('should preserve content without memory tags', () => {
it('preserves content without memory tags', () => {
const input = 'User: Hello\nAssistant: Hi there!';
const expected = 'User: Hello\nAssistant: Hi there!';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
expect(stripMemoryTags(input)).toBe(input);
});
it('should handle nested-like content without actual nesting', () => {
const input = '<hindsight_memories>Outer start\n</hindsight_memories>\nSafe content\n<hindsight_memories>\nOuter end</hindsight_memories>';
const expected = '\nSafe content\n';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
it('strips both tag types in same content', () => {
const input =
'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
expect(stripMemoryTags(input)).toBe('A\n\nB\n\nC');
});
it('should strip both tag types in same content', () => {
const input = 'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
const expected = 'A\n\nB\n\nC';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
});
it('should handle real-world agent conversation with injected memories', () => {
const input = '[role: system]\n<hindsight_memories>\nRelevant memories from past conversations (score 1=highest, prioritize recent when conflicting):\n[\n {\n "content": "User prefers dark mode",\n "relevance_score": 0.95\n }\n]\n\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nBased on your previous preference, let me help you enable dark mode.\n[assistant:end]';
it('strips tags from a real-world agent conversation with injected memories', () => {
const input =
'[role: system]\n<hindsight_memories>\nRelevant memories:\n[{"text": "User prefers dark mode"}]\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nLet me help you enable dark mode.\n[assistant:end]';
const result = stripMemoryTags(input);
// Should not contain the memory tags
expect(result).not.toContain('<hindsight_memories>');
expect(result).not.toContain('</hindsight_memories>');
expect(result).not.toContain('Relevant memories from past conversations');
// Should still contain the actual conversation
expect(result).not.toContain('User prefers dark mode');
expect(result).toContain('[role: user]');
expect(result).toContain('How do I enable dark mode?');
expect(result).toContain('[role: assistant]');
});
});
// ---------------------------------------------------------------------------
// extractRecallQuery
// ---------------------------------------------------------------------------
describe('extractRecallQuery', () => {
it('returns rawMessage when it is long enough', () => {
expect(extractRecallQuery('What is my favorite food?', undefined)).toBe(
'What is my favorite food?',
);
});
it('returns null when rawMessage is too short and prompt is absent', () => {
expect(extractRecallQuery('Hi', undefined)).toBeNull();
expect(extractRecallQuery('', '')).toBeNull();
expect(extractRecallQuery(undefined, undefined)).toBeNull();
});
it('returns null when both rawMessage and prompt are too short', () => {
expect(extractRecallQuery('Hey', 'Hey')).toBeNull();
});
it('falls back to prompt when rawMessage is absent', () => {
const result = extractRecallQuery(undefined, 'What programming language do I prefer?');
expect(result).toBe('What programming language do I prefer?');
});
it('strips leading System: lines from prompt', () => {
const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).toContain('What is my name?');
});
it('strips [Channel] envelope header and returns inner message', () => {
const prompt = '[Telegram Chat]\nWhat is my favorite hobby?';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBe('What is my favorite hobby?');
});
it('strips [from: SenderName] footer from group chat prompts', () => {
const prompt = '[Slack Channel #general]\nWhat should I eat for lunch?\n[from: Alice]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('[from: Alice]');
expect(result).toContain('What should I eat for lunch?');
});
it('handles full envelope with System lines, channel header, and from footer', () => {
const prompt =
'System: You are a helpful agent.\n\n[Discord Server]\nRemind me what I said about Python?\n[from: Bob]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).not.toContain('[Discord');
expect(result).not.toContain('[from: Bob]');
expect(result).toContain('Remind me what I said about Python?');
});
it('strips session abort hint from prompt', () => {
const prompt =
'Note: The previous agent run was aborted by the user\n\n[Telegram]\nWhat is my cat\'s name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('Note: The previous agent run was aborted');
expect(result).toContain("What is my cat's name?");
});
it('returns null when prompt reduces to < 5 chars after stripping', () => {
// Envelope with almost-empty inner message
const prompt = '[Telegram Chat]\nHi';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBeNull();
});
it('prefers rawMessage over prompt even when prompt is longer', () => {
const rawMessage = 'What do I like to eat?';
const prompt = '[Telegram]\nWhat do I like to eat?\n[from: Alice]';
const result = extractRecallQuery(rawMessage, prompt);
// Should return the clean rawMessage verbatim
expect(result).toBe(rawMessage);
expect(result).not.toContain('[from: Alice]');
});
it('trims whitespace from result', () => {
const result = extractRecallQuery(' What is my job? ', undefined);
expect(result).toBe('What is my job?');
});
});
+67 -42
View File
@@ -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]`;
})
+15 -7
View File
@@ -72,16 +72,24 @@ export interface RecallRequest {
export interface RecallResponse {
results: MemoryResult[];
entities: Record<string, unknown> | null;
trace: unknown | null;
chunks: unknown | null;
}
export interface MemoryResult {
content: string;
score: number;
metadata?: {
document_id?: string;
created_at?: string;
source?: string;
};
id: string;
text: string;
type: string;
entities: string[];
context: string;
occurred_start: string | null;
occurred_end: string | null;
mentioned_at: string | null;
document_id: string | null;
metadata: Record<string, unknown> | null;
chunk_id: string | null;
tags: string[];
}
export interface CreateBankRequest {
@@ -0,0 +1,542 @@
/**
* Integration tests for the OpenClaw plugin hooks.
*
* Loads the plugin with a mock MoltbotPluginAPI in HTTP mode, then triggers
* `before_agent_start` and `agent_end` hooks with realistic event payloads.
* Client methods (recall / retain) are spied on to verify the plugin
* orchestrates them correctly without requiring a full LLM pipeline.
*
* Requirements:
* Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import type { HindsightClient } from '../src/client.js';
import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js';
import type { RecallResponse, RetainResponse } from '../src/types.js';
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
/* not ready yet */
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
interface MockApiHandle {
api: MoltbotPluginAPI;
/** Trigger a registered hook and return the last handler's return value. */
trigger(event: string, eventData: unknown, ctx?: unknown): Promise<unknown>;
startServices(): Promise<void>;
stopServices(): Promise<void>;
}
function createMockApi(pluginConfig: Partial<PluginConfig> = {}): MockApiHandle {
const handlers = new Map<string, ((event: unknown, ctx?: unknown) => unknown)[]>();
const services: { id: string; start(): Promise<void>; stop(): Promise<void> }[] = [];
const api: MoltbotPluginAPI = {
config: {
plugins: {
entries: {
'hindsight-openclaw': { enabled: true, config: pluginConfig as PluginConfig },
},
},
},
registerService(svc: any) {
services.push(svc);
},
on(event: string, handler: any) {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
};
return {
api,
async trigger(event, eventData, ctx) {
const list = handlers.get(event) ?? [];
let result: unknown;
for (const h of list) result = await h(eventData, ctx);
return result;
},
async startServices() {
for (const svc of services) await svc.start();
},
async stopServices() {
for (const svc of services) await svc.stop();
},
};
}
const EMPTY_RECALL: RecallResponse = { results: [], entities: null, trace: null, chunks: null };
const OK_RETAIN: RetainResponse = { message: 'queued', document_id: 'test', memory_unit_ids: [] };
function makeMemoryResult(text: string) {
return {
id: `mem-${Math.random().toString(36).slice(2)}`,
text,
type: 'fact',
entities: [],
context: '',
occurred_start: null,
occurred_end: null,
mentioned_at: null,
document_id: null,
metadata: null,
chunk_id: null,
tags: [],
};
}
// ---------------------------------------------------------------------------
// Module-level state shared across all hook describe blocks
// ---------------------------------------------------------------------------
let apiReachable = false;
let triggerHook: MockApiHandle['trigger'];
let stopServicesFn: () => Promise<void>;
let recallSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
let retainSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
beforeAll(async () => {
apiReachable = await waitForApi(HINDSIGHT_API_URL, 8000);
if (!apiReachable) {
console.warn(
`[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} skipping hook tests.`,
);
return;
}
// Reset module registry so we get a fresh module with clean state.
vi.resetModules();
// Provide LLM config — used by plugin init even in HTTP mode.
process.env.HINDSIGHT_API_LLM_PROVIDER = 'openai';
process.env.HINDSIGHT_API_LLM_API_KEY = 'test-key-hooks';
// Point the plugin at the running test API.
process.env.HINDSIGHT_EMBED_API_URL = HINDSIGHT_API_URL;
const mod = await import('../src/index.js');
const pluginFn = mod.default;
const getClient = mod.getClient;
const handle = createMockApi({
dynamicBankId: true,
excludeProviders: ['slack'],
// No bankMission — keeps init lean
});
triggerHook = handle.trigger;
stopServicesFn = handle.stopServices;
// Load the plugin — registers hooks and starts background init.
pluginFn(handle.api);
// service.start() awaits initPromise and health-checks the external API.
await handle.startServices();
// After startServices the client must be ready.
const c = getClient();
if (!c) throw new Error('[Hooks Integration] Client not initialized after service start');
recallSpy = vi.spyOn(c, 'recall') as ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
retainSpy = vi.spyOn(c, 'retain') as ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
}, 30_000);
afterAll(async () => {
vi.restoreAllMocks();
delete process.env.HINDSIGHT_API_LLM_PROVIDER;
delete process.env.HINDSIGHT_API_LLM_API_KEY;
delete process.env.HINDSIGHT_EMBED_API_URL;
if (stopServicesFn) await stopServicesFn().catch(() => {});
}, 15_000);
afterEach(() => {
// Reset spy call history between tests; don't remove the implementation.
recallSpy?.mockReset();
retainSpy?.mockReset();
});
// ---------------------------------------------------------------------------
// before_agent_start
// ---------------------------------------------------------------------------
describe('before_agent_start hook', () => {
it('skips recall for excluded providers and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What are my preferences?', prompt: 'What are my preferences?' },
{ messageProvider: 'slack', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('skips recall when rawMessage is too short and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'Hi', prompt: 'Hi' },
{ messageProvider: 'telegram', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('returns undefined when recall finds no results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I like?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U002' },
);
expect(recallSpy).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it('returns { prependContext } with <hindsight_memories> when recall returns results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User likes Python')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I prefer?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U003' },
)) as { prependContext: string };
expect(result).toBeDefined();
expect(result.prependContext).toContain('<hindsight_memories>');
expect(result.prependContext).toContain('User likes Python');
expect(result.prependContext).toContain('</hindsight_memories>');
});
it('injects all memory result fields in the prependContext JSON', async () => {
if (!apiReachable) return;
const mem = makeMemoryResult('User prefers dark mode');
mem.tags = ['preference'];
mem.entities = ['dark_mode'];
recallSpy.mockResolvedValue({
results: [mem],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U004' },
)) as { prependContext: string };
// The prependContext should be valid JSON containing all MemoryResult fields
const jsonStart = result.prependContext.indexOf('[');
const jsonEnd = result.prependContext.lastIndexOf(']') + 1;
const parsed = JSON.parse(result.prependContext.slice(jsonStart, jsonEnd)) as unknown[];
expect(parsed).toHaveLength(1);
const first = parsed[0] as Record<string, unknown>;
expect(first.id).toBe(mem.id);
expect(first.text).toBe('User prefers dark mode');
expect(first.type).toBe('fact');
expect(first.tags).toEqual(['preference']);
expect(first.entities).toEqual(['dark_mode']);
});
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const envelopePrompt = '[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]';
await triggerHook(
'before_agent_start',
{ rawMessage: '', prompt: envelopePrompt },
{ messageProvider: 'telegram', senderId: 'U005' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
// The query passed to recall must NOT contain envelope artifacts
expect(callArgs.query).not.toContain('[Telegram');
expect(callArgs.query).not.toContain('[from: Alice]');
expect(callArgs.query).toContain('What is my favorite food?');
});
it('passes max_tokens to recall', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_agent_start',
{ rawMessage: 'Tell me about my hobbies please.', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U006' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
expect(callArgs.max_tokens).toBeGreaterThan(0);
});
it('includes the user message in the prependContext block', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User loves hiking')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U007' },
)) as { prependContext: string };
expect(result.prependContext).toContain('What outdoor activities do I enjoy?');
});
});
// ---------------------------------------------------------------------------
// agent_end hook
// ---------------------------------------------------------------------------
describe('agent_end hook', () => {
it('skips retain when success is false', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: false, messages: [{ role: 'user', content: 'Hello there world!' }] },
{ messageProvider: 'telegram', senderId: 'U010' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain when messages array is empty', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: true, messages: [] },
{ messageProvider: 'telegram', senderId: 'U011' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain for excluded providers', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'I work as a software engineer.' }],
},
{ messageProvider: 'slack', senderId: 'U012' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('calls retain with correctly formatted transcript for string content', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'I love TypeScript.' },
{ role: 'assistant', content: 'TypeScript is great!' },
],
},
{ messageProvider: 'telegram', senderId: 'U013', sessionKey: 'sess-ts-test' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('[role: user]');
expect(req.content).toContain('I love TypeScript.');
expect(req.content).toContain('[user:end]');
expect(req.content).toContain('[role: assistant]');
expect(req.content).toContain('TypeScript is great!');
expect(req.content).toContain('[assistant:end]');
});
it('includes session key in document_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My favourite colour is blue.' }],
},
{ messageProvider: 'telegram', senderId: 'U014', sessionKey: 'sess-colour' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.document_id).toContain('sess-colour');
});
it('populates metadata with channel_type, channel_id, and sender_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My cat is named Whiskers.' }],
},
{
messageProvider: 'telegram',
channelId: 'chat-999',
senderId: 'U015',
sessionKey: 'sess-cat',
},
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.metadata?.channel_type).toBe('telegram');
expect(req.metadata?.channel_id).toBe('chat-999');
expect(req.metadata?.sender_id).toBe('U015');
expect(req.metadata?.retained_at).toBeDefined();
expect(req.metadata?.message_count).toBe('1');
});
it('strips <hindsight_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithMemories =
'<hindsight_memories>\nRelevant memories:\n[{"text":"old fact"}]\n</hindsight_memories>\nI enjoy reading science fiction.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithMemories }],
},
{ messageProvider: 'telegram', senderId: 'U016', sessionKey: 'sess-strip' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<hindsight_memories>');
expect(req.content).not.toContain('</hindsight_memories>');
expect(req.content).not.toContain('old fact');
expect(req.content).toContain('I enjoy reading science fiction.');
});
it('strips <relevant_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithLegacyTag =
'<relevant_memories>\nSome old memories\n</relevant_memories>\nI am learning Rust.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithLegacyTag }],
},
{ messageProvider: 'telegram', senderId: 'U017', sessionKey: 'sess-legacy' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<relevant_memories>');
expect(req.content).toContain('I am learning Rust.');
});
it('handles array content blocks (structured message format)', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'I prefer dark mode in all my editors.' },
{ type: 'image', source: 'data:...' }, // non-text block — should be ignored
],
},
],
},
{ messageProvider: 'telegram', senderId: 'U018', sessionKey: 'sess-array' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('I prefer dark mode in all my editors.');
// Image block text should not appear
expect(req.content).not.toContain('data:');
});
it('retains a multi-turn conversation in the correct transcript format', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'My name is Carol.' },
{ role: 'assistant', content: 'Nice to meet you, Carol!' },
{ role: 'user', content: 'I work as a data scientist.' },
{ role: 'assistant', content: "That's a fascinating career!" },
],
},
{ messageProvider: 'telegram', senderId: 'U019', sessionKey: 'sess-multi' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
// Each message should appear in the correct envelope format
expect(req.content).toContain('[role: user]\nMy name is Carol.\n[user:end]');
expect(req.content).toContain('[role: assistant]\nNice to meet you, Carol!\n[assistant:end]');
expect(req.content).toContain('[role: user]\nI work as a data scientist.\n[user:end]');
expect(req.metadata?.message_count).toBe('4');
});
});
@@ -0,0 +1,385 @@
/**
* Integration tests for the Hindsight OpenClaw integration.
*
* Tests both HTTP mode (direct API calls) and Embed mode (subprocess/daemon).
*
* Requirements:
* HTTP mode: Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
* Embed mode: hindsight-embed package at HINDSIGHT_EMBED_PACKAGE_PATH
* + LLM credentials (HINDSIGHT_API_LLM_PROVIDER / HINDSIGHT_API_LLM_API_KEY)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HindsightClient } from '../src/client.js';
import { HindsightEmbedManager } from '../src/embed-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ---------------------------------------------------------------------------
// Test configuration (driven by environment variables)
// ---------------------------------------------------------------------------
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || '';
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || '';
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || '';
// Embed package path defaults to the sibling hindsight-embed directory in the repo
const EMBED_PACKAGE_PATH =
process.env.HINDSIGHT_EMBED_PACKAGE_PATH ||
join(__dirname, '..', '..', '..', 'hindsight-embed');
// Port for the test embed daemon (different from production default 9077 to avoid conflicts)
const EMBED_TEST_PORT = 19077;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomBankId(): string {
return `openclaw_test_${Math.random().toString(36).slice(2, 14)}`;
}
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
// ---------------------------------------------------------------------------
// HTTP Mode Tests
// ---------------------------------------------------------------------------
describe('HindsightClient HTTP Mode', () => {
let client: HindsightClient;
beforeAll(async () => {
const reachable = await waitForApi(HINDSIGHT_API_URL);
if (!reachable) {
throw new Error(
`Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` +
'Start the server before running integration tests.',
);
}
client = new HindsightClient({
llmProvider: LLM_PROVIDER || 'openai',
llmApiKey: LLM_API_KEY || 'test-key',
llmModel: LLM_MODEL || undefined,
apiUrl: HINDSIGHT_API_URL,
});
});
it('should retain a conversation', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' +
'[role: assistant]\nNice to meet you, Alice!\n[assistant:end]',
document_id: 'http-retain-test-1',
metadata: { channel_type: 'slack', sender_id: 'U001' },
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('http-retain-test-1');
});
it('should retain with auto-generated document id', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI work at TechCorp as a software engineer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
});
it('should recall from an empty bank without error', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should set bank mission without throwing', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// setBankMission on a non-existent bank logs a warning but does not throw
await expect(
client.setBankMission('You are an assistant helping users via Slack.'),
).resolves.not.toThrow();
});
it('should set bank mission after retain creates the bank', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// Create the bank by retaining something first
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
// Now set the mission bank exists so this should succeed
await expect(
client.setBankMission('You are a helpful AI assistant.'),
).resolves.not.toThrow();
});
it('should retain and then recall relevant memories', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' +
'[role: assistant]\nPython is a great choice!\n[assistant:end]',
document_id: `session-${Date.now()}`,
});
const response = await client.recall({
query: 'What programming language do I like?',
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should silently truncate recall queries over 800 chars', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const longQuery = 'Tell me about my interests. '.repeat(50); // > 800 chars
const response = await client.recall({ query: longQuery, max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should use custom max_tokens in recall request', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'anything', max_tokens: 256 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should map recall results to MemoryResult shape', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' +
'[role: assistant]\nSounds like a great hobby!\n[assistant:end]',
document_id: 'mapping-test',
});
const response = await client.recall({ query: 'What are my hobbies?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Embed Mode Tests (subprocess / daemon)
// ---------------------------------------------------------------------------
describe('HindsightClient Embed Mode (Subprocess)', () => {
let client: HindsightClient;
let embedManager: HindsightEmbedManager;
const hasEmbedCredentials = Boolean(LLM_PROVIDER && LLM_API_KEY);
beforeAll(async () => {
if (!hasEmbedCredentials) {
console.warn(
'[Integration] Skipping embed mode tests: ' +
'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.',
);
return;
}
embedManager = new HindsightEmbedManager(
EMBED_TEST_PORT,
LLM_PROVIDER,
LLM_API_KEY,
LLM_MODEL || undefined,
undefined, // no custom base URL
0, // never idle-timeout
'latest',
EMBED_PACKAGE_PATH,
);
await embedManager.start();
client = new HindsightClient({
llmProvider: LLM_PROVIDER,
llmApiKey: LLM_API_KEY,
llmModel: LLM_MODEL || undefined,
embedPackagePath: EMBED_PACKAGE_PATH,
});
}, 120_000); // daemon startup can take up to 2 minutes
afterAll(async () => {
if (embedManager) {
await embedManager.stop();
}
}, 30_000);
it('should retain a conversation via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' +
'[role: assistant]\nSounds adventurous!\n[assistant:end]',
document_id: 'embed-retain-test-1',
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('embed-retain-test-1');
}, 60_000);
it('should retain with auto-generated document id via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI am a TypeScript developer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
}, 60_000);
it('should recall from an empty bank without error via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should set bank mission via subprocess without throwing', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Create bank by retaining first, then set mission
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
await expect(
client.setBankMission('Test mission for embed integration tests.'),
).resolves.not.toThrow();
}, 60_000);
it('should retain and then recall relevant memories via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' +
'[role: assistant]\nWhat a lovely name!\n[assistant:end]',
document_id: `embed-e2e-${Date.now()}`,
});
const response = await client.recall({
query: "What is my cat's name?",
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should map recall results to MemoryResult shape via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy cooking Italian food.\n[user:end]\n\n' +
'[role: assistant]\nItalian cuisine is delicious!\n[assistant:end]',
document_id: 'embed-shape-test',
});
const response = await client.recall({ query: 'What food do I like?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
}, 60_000);
it('should handle full end-to-end workflow via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Step 1: Retain
const retainResp = await client.retain({
content:
'[role: user]\nI am learning Rust programming.\n[user:end]\n\n' +
'[role: assistant]\nRust is a powerful systems language!\n[assistant:end]',
document_id: `embed-workflow-${Date.now()}`,
metadata: { channel_type: 'telegram', sender_id: '999' },
});
expect(retainResp).toBeDefined();
// Step 2: Recall
const recallResp = await client.recall({
query: 'What am I learning?',
max_tokens: 1024,
});
expect(recallResp).toBeDefined();
expect(Array.isArray(recallResp.results)).toBe(true);
}, 60_000);
});
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
testTimeout: 120_000,
hookTimeout: 120_000,
reporters: ['verbose'],
},
});