Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 3f978b446d fix: remove unused PluginState import from tools.ts 2026-04-07 10:05:36 +02:00
Nicolò Boschi a4a9d32480 fix: review fixes for opencode integration
- Rename CI job from build-opencode-integration to test-opencode-integration
  to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files
2026-04-07 09:49:19 +02:00
DK09876andClaude Opus 4.6 8b4c7eeb8e fix: recall retry semantics and README bank scoping clarity
1. recallForContext now returns { context, ok } to distinguish
   "no results" (ok=true) from "API error" (ok=false). System
   transform consumes the session on ok=true even with 0 results,
   so empty banks don't cause repeated queries. Only transient API
   failures preserve retry.

2. README clarifies that channel/user bank dimensions are process-
   scoped (set via env vars before launch), not per-session dynamic
   within a running OpenCode process.

89 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 311b96a192 fix: docs/tools findings from second review round
1. Remove "session" from supported dynamic bank fields in docs —
   the implementation can't vary bank ID per session since it's
   derived once at plugin startup.

2. Explicit tools (retain, reflect) now call ensureBankMission()
   before API calls, so bankMission/retainMission are applied even
   when the agent uses tools exclusively without triggering hooks.

3. Added tests for mission setup via tools path.

88 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 fdc48b1544 fix: address review findings for opencode integration
1. Pre-compaction retain now uses shared retainSession() helper,
   respecting retainMode, documentId, and session_id metadata
   consistently with idle-retain (was bypassing retention policy).

2. System transform recall is only consumed after successful injection.
   If Hindsight is briefly unavailable, the plugin retries on the next
   LLM call instead of permanently skipping recall for the session.

3. Config validation for retainMode and recallBudget — typos like
   "full_session" or "maximum" now log a warning and fall back to
   the default instead of silently changing retention semantics.

85 tests (6 new covering compaction documentId, recall retry, and
config validation).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
DK09876andClaude Opus 4.6 ebb05cf9ca feat: add OpenCode persistent memory plugin
Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page

79 tests across 6 test files.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-07 09:45:38 +02:00
24 changed files with 5346 additions and 1 deletions
+35
View File
@@ -46,6 +46,7 @@ jobs:
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
@@ -120,6 +121,8 @@ jobs:
- 'hindsight-integrations/llamaindex/**'
integrations-paperclip:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
dev:
- 'hindsight-dev/**'
ci:
@@ -329,6 +332,37 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
test-opencode-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/opencode
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/opencode
run: npm test
- name: Build
working-directory: ./hindsight-integrations/opencode
run: npm run build
build-chat-integration:
needs: [detect-changes]
if: >-
@@ -2461,6 +2495,7 @@ jobs:
- test-codex-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- build-chat-integration
- test-paperclip-integration
- build-control-plane
@@ -0,0 +1,141 @@
---
sidebar_position: 20
title: "OpenCode Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to OpenCode with Hindsight. Automatically captures conversations and recalls relevant context across coding sessions."
---
# OpenCode
Persistent long-term memory plugin for [OpenCode](https://opencode.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations, recalls relevant context on session start, and provides retain/recall/reflect tools the agent can call directly.
## Quick Start
```bash
# 1. Install the plugin
npm install @vectorize-io/opencode-hindsight
```
Add to your `opencode.json`:
```json
{
"plugin": ["@vectorize-io/opencode-hindsight"]
}
```
```bash
# 2. Configure your Hindsight server
export HINDSIGHT_API_URL="http://localhost:8888"
# Optional: API key for Hindsight Cloud
export HINDSIGHT_API_TOKEN="your-api-key"
# 3. Start OpenCode — the plugin activates automatically
opencode
```
## Features
### Custom Tools
The plugin registers three tools the agent can call explicitly:
| Tool | Description |
|---|---|
| `hindsight_retain` | Store information in long-term memory |
| `hindsight_recall` | Search long-term memory for relevant information |
| `hindsight_reflect` | Generate a synthesized answer from long-term memory |
### Auto-Retain
When the session goes idle (`session.idle` event), the plugin automatically retains the conversation transcript to Hindsight. Configurable via `retainEveryNTurns` to control frequency.
### Session Recall
When a new session starts, the plugin recalls relevant project context and injects it into the system prompt, giving the agent access to memories from prior sessions.
### Compaction Hook
When OpenCode compacts the context window, the plugin:
1. Retains the current conversation before compaction
2. Recalls relevant memories and injects them into the compaction context
This ensures memories survive context window trimming.
## Configuration
### Plugin Options
```json
{
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid",
"retainEveryNTurns": 10,
"debug": false
}]
]
}
```
### Config File
Create `~/.hindsight/opencode.json` for persistent configuration that applies across all projects:
```json
{
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"recallBudget": "mid"
}
```
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | *(required)* |
| `HINDSIGHT_API_TOKEN` | API key for authentication | |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context for reflect | |
| `HINDSIGHT_DEBUG` | Enable debug logging to stderr | `false` |
Configuration priority (later wins): defaults < `~/.hindsight/opencode.json` < plugin options < env vars.
## Dynamic Bank IDs
For multi-project isolation, enable dynamic bank ID derivation:
```bash
export HINDSIGHT_DYNAMIC_BANK_ID=true
```
The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`.
For multi-user scenarios (e.g., shared agent serving multiple users):
```bash
export HINDSIGHT_CHANNEL_ID="slack-general"
export HINDSIGHT_USER_ID="user123"
```
## How It Works
1. **Plugin loads** when OpenCode starts — creates a `HindsightClient`, derives the bank ID, and registers tools + hooks
2. **Session starts**`session.created` event triggers, plugin marks session for recall injection
3. **System transform** — on the first LLM call, recalled memories are injected into the system prompt
4. **Agent works** — can call `hindsight_recall` and `hindsight_retain` explicitly during the session
5. **Session idles**`session.idle` event triggers auto-retain of the conversation
6. **Compaction** — if the context window fills up, memories are preserved through the compaction
+10
View File
@@ -180,6 +180,16 @@
"link": "/sdks/integrations/autogen",
"icon": "/img/icons/autogen.svg"
},
{
"id": "opencode",
"name": "OpenCode",
"description": "Persistent long-term memory plugin for OpenCode. Auto-retains conversations, recalls context on session start, and provides retain/recall/reflect tools.",
"type": "official",
"by": "hindsight",
"category": "tool",
"link": "/sdks/integrations/opencode",
"icon": "/img/icons/opencode.svg"
},
{
"id": "hindclaw",
"name": "HindClaw",
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
<text x="32" y="42" font-family="monospace" font-size="28" font-weight="bold" fill="#00d4ff" text-anchor="middle">OC</text>
</svg>

After

Width:  |  Height:  |  Size: 262 B

+144
View File
@@ -0,0 +1,144 @@
# @vectorize-io/opencode-hindsight
Hindsight memory plugin for [OpenCode](https://opencode.ai) — give your AI coding agent persistent long-term memory across sessions.
## Features
- **Custom tools**: `hindsight_retain`, `hindsight_recall`, `hindsight_reflect` — the agent calls these explicitly
- **Auto-retain**: Captures conversation on `session.idle` and stores to Hindsight
- **Memory injection**: Recalls relevant memories when a new session starts
- **Compaction hook**: Injects memories during context compaction so they survive window trimming
## Quick Start
### 1. Install
```bash
npm install @vectorize-io/opencode-hindsight
```
### 2. Configure
Add to your `opencode.json`:
```json
{
"plugin": ["@vectorize-io/opencode-hindsight"]
}
```
### 3. Set Environment Variables
```bash
# Required: Hindsight API URL
export HINDSIGHT_API_URL="http://localhost:8888"
# Optional: API key for Hindsight Cloud
export HINDSIGHT_API_TOKEN="your-api-key"
# Optional: Override the memory bank ID
export HINDSIGHT_BANK_ID="my-project"
```
## Configuration
### Plugin Options
Pass options directly in `opencode.json`:
```json
{
"plugin": [
["@vectorize-io/opencode-hindsight", {
"hindsightApiUrl": "http://localhost:8888",
"bankId": "my-project",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid"
}]
]
}
```
### Config File
Create `~/.hindsight/opencode.json` for persistent configuration:
```json
{
"hindsightApiUrl": "http://localhost:8888",
"hindsightApiToken": "your-api-key",
"recallBudget": "mid",
"retainEveryNTurns": 10,
"debug": false
}
```
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) |
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) |
| `HINDSIGHT_DEBUG` | Enable debug logging | `false` |
### Configuration Priority
Settings are loaded in this order (later wins):
1. Built-in defaults
2. `~/.hindsight/opencode.json`
3. Plugin options from `opencode.json`
4. Environment variables
## Tools
### `hindsight_retain`
Store information in long-term memory. The agent uses this to save important facts, user preferences, project context, and decisions.
### `hindsight_recall`
Search long-term memory. The agent uses this proactively before answering questions where prior context would help.
### `hindsight_reflect`
Generate a synthesized answer from long-term memory. Unlike recall (raw memories), reflect produces a coherent summary.
## Dynamic Bank IDs
For multi-project setups, enable dynamic bank ID derivation:
```bash
export HINDSIGHT_DYNAMIC_BANK_ID=true
```
The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`.
**Note:** The bank ID is derived once when the plugin loads, from environment variables set before OpenCode starts. These dimensions are process-scoped — they don't change per session within a running OpenCode process. For per-user isolation, set the env vars before launching each user's OpenCode instance:
```bash
export HINDSIGHT_CHANNEL_ID="slack-general"
export HINDSIGHT_USER_ID="user123"
```
## Development
```bash
npm install
npm test # Run tests
npm run build # Build to dist/
```
## License
MIT
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
{
"name": "@vectorize-io/opencode-hindsight",
"version": "0.1.0",
"description": "Hindsight memory plugin for OpenCode - Give your AI coding agent persistent long-term memory",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"opencode",
"ai",
"memory",
"hindsight",
"agents",
"llm",
"long-term-memory",
"coding-agent"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/opencode"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run clean && npm run build"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.0.0"
},
"dependencies": {
"@vectorize-io/hindsight-client": "^0.4.19"
},
"devDependencies": {
"@opencode-ai/plugin": "^1.3.13",
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=22"
},
"overrides": {
"rollup": "^4.59.0",
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4"
}
}
@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { deriveBankId, ensureBankMission } from './bank.js';
import { makeConfig } from './test-helpers.js';
describe('deriveBankId', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns default bank name in static mode', () => {
expect(deriveBankId(makeConfig(), '/home/user/project')).toBe('opencode');
});
it('returns configured bankId in static mode', () => {
const config = makeConfig({ bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('my-bank');
});
it('adds prefix in static mode', () => {
const config = makeConfig({ bankIdPrefix: 'dev', bankId: 'my-bank' });
expect(deriveBankId(config, '/home/user/project')).toBe('dev-my-bank');
});
it('composes from granularity fields in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'project'],
agentName: 'opencode',
});
expect(deriveBankId(config, '/home/user/my-project')).toBe('opencode::my-project');
});
it('uses default granularity when not specified', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: [],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::proj');
});
it('URL-encodes special characters', () => {
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['project'],
});
expect(deriveBankId(config, '/home/user/my project')).toBe('my%20project');
});
it('uses channel/user from env vars', () => {
process.env.HINDSIGHT_CHANNEL_ID = 'slack-general';
process.env.HINDSIGHT_USER_ID = 'user123';
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['agent', 'channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('opencode::slack-general::user123');
});
it('uses defaults for missing env vars', () => {
delete process.env.HINDSIGHT_CHANNEL_ID;
delete process.env.HINDSIGHT_USER_ID;
const config = makeConfig({
dynamicBankId: true,
dynamicBankGranularity: ['channel', 'user'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('default::anonymous');
});
it('adds prefix in dynamic mode', () => {
const config = makeConfig({
dynamicBankId: true,
bankIdPrefix: 'dev',
dynamicBankGranularity: ['agent'],
});
expect(deriveBankId(config, '/home/user/proj')).toBe('dev-opencode');
});
});
describe('ensureBankMission', () => {
it('calls createBank on first use', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Test mission' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Test mission',
retainMission: undefined,
});
expect(missionsSet.has('test-bank')).toBe(true);
});
it('skips if already set', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set(['test-bank']);
const config = makeConfig({ bankMission: 'Test mission' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
it('skips if no mission configured', async () => {
const client = { createBank: vi.fn() } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: '' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).not.toHaveBeenCalled();
});
it('does not throw on client error', async () => {
const client = { createBank: vi.fn().mockRejectedValue(new Error('Network error')) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Mission' });
await expect(
ensureBankMission(client, 'test-bank', config, missionsSet),
).resolves.not.toThrow();
expect(missionsSet.has('test-bank')).toBe(false);
});
it('passes retainMission when configured', async () => {
const client = { createBank: vi.fn().mockResolvedValue({}) } as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Reflect', retainMission: 'Extract carefully' });
await ensureBankMission(client, 'test-bank', config, missionsSet);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Reflect',
retainMission: 'Extract carefully',
});
});
});
@@ -0,0 +1,94 @@
/**
* Bank ID derivation and mission management.
*
* Port of Claude Code plugin's bank.py, adapted for OpenCode's context model.
*
* Dimensions for dynamic bank IDs:
* - agent → configured name or "opencode"
* - project → derived from working directory basename
*/
import { basename } from 'node:path';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import type { HindsightClient } from '@vectorize-io/hindsight-client';
const DEFAULT_BANK_NAME = 'opencode';
const VALID_FIELDS = new Set(['agent', 'project', 'channel', 'user']);
/**
* Derive a bank ID from context and config.
*
* Static mode: returns config.bankId or DEFAULT_BANK_NAME.
* Dynamic mode: composes from granularity fields joined by '::'.
*/
export function deriveBankId(config: HindsightConfig, directory: string): string {
const prefix = config.bankIdPrefix;
if (!config.dynamicBankId) {
const base = config.bankId || DEFAULT_BANK_NAME;
return prefix ? `${prefix}-${base}` : base;
}
const fields = config.dynamicBankGranularity?.length
? config.dynamicBankGranularity
: ['agent', 'project'];
for (const f of fields) {
if (!VALID_FIELDS.has(f)) {
console.error(
`[Hindsight] Unknown dynamicBankGranularity field "${f}" — ` +
`valid: ${[...VALID_FIELDS].sort().join(', ')}`,
);
}
}
const channelId = process.env.HINDSIGHT_CHANNEL_ID || '';
const userId = process.env.HINDSIGHT_USER_ID || '';
const fieldMap: Record<string, string> = {
agent: config.agentName || 'opencode',
project: directory ? basename(directory) : 'unknown',
channel: channelId || 'default',
user: userId || 'anonymous',
};
const segments = fields.map((f) => encodeURIComponent(fieldMap[f] || 'unknown'));
const baseBankId = segments.join('::');
return prefix ? `${prefix}-${baseBankId}` : baseBankId;
}
/**
* Set bank mission on first use, skip if already set.
* Uses an in-memory Set (plugin is long-lived, unlike Claude Code's ephemeral hooks).
*/
export async function ensureBankMission(
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet: Set<string>,
): Promise<void> {
const mission = config.bankMission;
if (!mission?.trim()) return;
if (missionsSet.has(bankId)) return;
try {
await client.createBank(bankId, {
reflectMission: mission,
retainMission: config.retainMission || undefined,
});
missionsSet.add(bankId);
// Cap tracked banks
if (missionsSet.size > 10000) {
const keys = [...missionsSet].sort();
for (const k of keys.slice(0, keys.length >> 1)) {
missionsSet.delete(k);
}
}
debugLog(config, `Set mission for bank: ${bankId}`);
} catch (e) {
// Don't fail if mission set fails — bank may not exist yet
debugLog(config, `Could not set bank mission for ${bankId}: ${e}`);
}
}
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { loadConfig, type HindsightConfig } from './config.js';
describe('loadConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear all HINDSIGHT_ env vars
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) {
delete process.env[key];
}
}
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns defaults when no config sources exist', () => {
const config = loadConfig();
expect(config.autoRecall).toBe(true);
expect(config.autoRetain).toBe(true);
expect(config.recallBudget).toBe('mid');
expect(config.recallMaxTokens).toBe(1024);
expect(config.retainContext).toBe('opencode');
expect(config.agentName).toBe('opencode');
expect(config.dynamicBankId).toBe(false);
expect(config.debug).toBe(false);
expect(config.hindsightApiUrl).toBeNull();
expect(config.hindsightApiToken).toBeNull();
expect(config.bankId).toBeNull();
});
it('env vars override defaults', () => {
process.env.HINDSIGHT_API_URL = 'https://example.com';
process.env.HINDSIGHT_API_TOKEN = 'secret-token';
process.env.HINDSIGHT_BANK_ID = 'my-bank';
process.env.HINDSIGHT_AUTO_RECALL = 'false';
process.env.HINDSIGHT_AUTO_RETAIN = '0';
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '2048';
process.env.HINDSIGHT_DEBUG = 'true';
const config = loadConfig();
expect(config.hindsightApiUrl).toBe('https://example.com');
expect(config.hindsightApiToken).toBe('secret-token');
expect(config.bankId).toBe('my-bank');
expect(config.autoRecall).toBe(false);
expect(config.autoRetain).toBe(false);
expect(config.recallMaxTokens).toBe(2048);
expect(config.debug).toBe(true);
});
it('plugin options override defaults', () => {
const config = loadConfig({
bankId: 'plugin-bank',
autoRecall: false,
recallBudget: 'high',
});
expect(config.bankId).toBe('plugin-bank');
expect(config.autoRecall).toBe(false);
expect(config.recallBudget).toBe('high');
});
it('env vars override plugin options', () => {
process.env.HINDSIGHT_BANK_ID = 'env-bank';
const config = loadConfig({ bankId: 'plugin-bank' });
expect(config.bankId).toBe('env-bank');
});
it('boolean env var parsing', () => {
process.env.HINDSIGHT_AUTO_RECALL = 'true';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = '1';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = 'yes';
expect(loadConfig().autoRecall).toBe(true);
process.env.HINDSIGHT_AUTO_RECALL = 'false';
expect(loadConfig().autoRecall).toBe(false);
process.env.HINDSIGHT_AUTO_RECALL = 'no';
expect(loadConfig().autoRecall).toBe(false);
});
it('integer env var parsing', () => {
process.env.HINDSIGHT_RECALL_MAX_TOKENS = '4096';
expect(loadConfig().recallMaxTokens).toBe(4096);
// Invalid integer keeps default
process.env.HINDSIGHT_RECALL_MAX_TOKENS = 'not-a-number';
expect(loadConfig().recallMaxTokens).toBe(1024);
});
it('null plugin options are ignored', () => {
const config = loadConfig({ bankId: null, debug: undefined });
expect(config.bankId).toBeNull(); // stays default null
expect(config.debug).toBe(false); // stays default
});
it('invalid retainMode falls back to full-session with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'full_session' });
expect(config.retainMode).toBe('full-session');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown retainMode'));
spy.mockRestore();
});
it('invalid recallBudget falls back to mid with warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ recallBudget: 'maximum' });
expect(config.recallBudget).toBe('mid');
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Unknown recallBudget'));
spy.mockRestore();
});
it('valid retainMode and recallBudget pass without warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = loadConfig({ retainMode: 'last-turn', recallBudget: 'high' });
expect(config.retainMode).toBe('last-turn');
expect(config.recallBudget).toBe('high');
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
@@ -0,0 +1,187 @@
/**
* Configuration management for the Hindsight OpenCode plugin.
*
* Loading order (later entries win):
* 1. Built-in defaults
* 2. User config file (~/.hindsight/opencode.json)
* 3. Plugin options (from opencode.json plugin tuple)
* 4. Environment variable overrides
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
export interface HindsightConfig {
// Recall
autoRecall: boolean;
recallBudget: string;
recallMaxTokens: number;
recallTypes: string[];
recallContextTurns: number;
recallMaxQueryChars: number;
recallPromptPreamble: string;
// Retain
autoRetain: boolean;
retainMode: string;
retainEveryNTurns: number;
retainOverlapTurns: number;
retainContext: string;
retainTags: string[];
retainMetadata: Record<string, string>;
// Connection
hindsightApiUrl: string | null;
hindsightApiToken: string | null;
// Bank
bankId: string | null;
bankIdPrefix: string;
dynamicBankId: boolean;
dynamicBankGranularity: string[];
bankMission: string;
retainMission: string | null;
agentName: string;
// Misc
debug: boolean;
}
const DEFAULTS: HindsightConfig = {
// Recall
autoRecall: true,
recallBudget: 'mid',
recallMaxTokens: 1024,
recallTypes: ['world', 'experience'],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallPromptPreamble:
'Relevant memories from past conversations (prioritize recent when ' +
'conflicting). Only use memories that are directly useful to continue ' +
'this conversation; ignore the rest:',
// Retain
autoRetain: true,
retainMode: 'full-session',
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: 'opencode',
retainTags: [],
retainMetadata: {},
// Connection
hindsightApiUrl: null,
hindsightApiToken: null,
// Bank
bankId: null,
bankIdPrefix: '',
dynamicBankId: false,
dynamicBankGranularity: ['agent', 'project'],
bankMission: '',
retainMission: null,
agentName: 'opencode',
// Misc
debug: false,
};
/** Env var → config key + type mapping */
const ENV_OVERRIDES: Record<string, [keyof HindsightConfig, 'string' | 'bool' | 'int']> = {
HINDSIGHT_API_URL: ['hindsightApiUrl', 'string'],
HINDSIGHT_API_TOKEN: ['hindsightApiToken', 'string'],
HINDSIGHT_BANK_ID: ['bankId', 'string'],
HINDSIGHT_AGENT_NAME: ['agentName', 'string'],
HINDSIGHT_AUTO_RECALL: ['autoRecall', 'bool'],
HINDSIGHT_AUTO_RETAIN: ['autoRetain', 'bool'],
HINDSIGHT_RETAIN_MODE: ['retainMode', 'string'],
HINDSIGHT_RECALL_BUDGET: ['recallBudget', 'string'],
HINDSIGHT_RECALL_MAX_TOKENS: ['recallMaxTokens', 'int'],
HINDSIGHT_RECALL_MAX_QUERY_CHARS: ['recallMaxQueryChars', 'int'],
HINDSIGHT_RECALL_CONTEXT_TURNS: ['recallContextTurns', 'int'],
HINDSIGHT_DYNAMIC_BANK_ID: ['dynamicBankId', 'bool'],
HINDSIGHT_BANK_MISSION: ['bankMission', 'string'],
HINDSIGHT_DEBUG: ['debug', 'bool'],
};
function castEnv(value: string, typ: 'string' | 'bool' | 'int'): string | boolean | number | null {
if (typ === 'bool') return ['true', '1', 'yes'].includes(value.toLowerCase());
if (typ === 'int') {
const n = parseInt(value, 10);
return isNaN(n) ? null : n;
}
return value;
}
function loadSettingsFile(path: string): Record<string, unknown> {
try {
const raw = readFileSync(path, 'utf-8');
return JSON.parse(raw);
} catch {
return {};
}
}
export function loadConfig(pluginOptions?: Record<string, unknown>): HindsightConfig {
// 1. Start with defaults
const config: Record<string, unknown> = { ...DEFAULTS };
// 2. User config file (~/.hindsight/opencode.json)
const userConfigPath = join(homedir(), '.hindsight', 'opencode.json');
const fileConfig = loadSettingsFile(userConfigPath);
for (const [key, value] of Object.entries(fileConfig)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
// 3. Plugin options (from opencode.json: ["@vectorize-io/opencode-hindsight", { ... }])
if (pluginOptions) {
for (const [key, value] of Object.entries(pluginOptions)) {
if (value !== null && value !== undefined) {
config[key] = value;
}
}
}
// 4. Environment variable overrides (highest priority)
for (const [envName, [key, typ]] of Object.entries(ENV_OVERRIDES)) {
const val = process.env[envName];
if (val !== undefined) {
const castVal = castEnv(val, typ);
if (castVal !== null) {
config[key] = castVal;
}
}
}
const result = config as unknown as HindsightConfig;
// Validate enum-like fields to catch typos early
const VALID_RETAIN_MODES = ['full-session', 'last-turn'];
if (!VALID_RETAIN_MODES.includes(result.retainMode)) {
console.error(
`[Hindsight] Unknown retainMode "${result.retainMode}" — ` +
`valid: ${VALID_RETAIN_MODES.join(', ')}. Falling back to "full-session".`,
);
result.retainMode = 'full-session';
}
const VALID_BUDGETS = ['low', 'mid', 'high'];
if (!VALID_BUDGETS.includes(result.recallBudget)) {
console.error(
`[Hindsight] Unknown recallBudget "${result.recallBudget}" — ` +
`valid: ${VALID_BUDGETS.join(', ')}. Falling back to "mid".`,
);
result.recallBudget = 'mid';
}
return result;
}
export function debugLog(config: HindsightConfig, ...args: unknown[]): void {
if (config.debug) {
console.error('[Hindsight]', ...args);
}
}
@@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest';
import {
stripMemoryTags,
formatMemories,
formatCurrentTime,
composeRecallQuery,
truncateRecallQuery,
sliceLastTurnsByUserBoundary,
prepareRetentionTranscript,
} from './content.js';
describe('stripMemoryTags', () => {
it('removes <hindsight_memories> blocks', () => {
const input = 'before <hindsight_memories>secret</hindsight_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
it('removes <relevant_memories> blocks', () => {
const input = 'before <relevant_memories>\nmultiline\n</relevant_memories> after';
expect(stripMemoryTags(input)).toBe('before after');
});
it('removes multiple blocks', () => {
const input = '<hindsight_memories>a</hindsight_memories> middle <relevant_memories>b</relevant_memories>';
expect(stripMemoryTags(input)).toBe(' middle ');
});
it('returns unchanged if no tags', () => {
expect(stripMemoryTags('hello world')).toBe('hello world');
});
});
describe('formatMemories', () => {
it('formats recall results with type and date', () => {
const results = [
{ text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' },
{ text: 'Met at conference', type: 'experience', mentioned_at: '2025-03-15' },
];
const formatted = formatMemories(results);
expect(formatted).toContain('- User likes Python [world] (2025-01-01)');
expect(formatted).toContain('- Met at conference [experience] (2025-03-15)');
});
it('handles missing type and date', () => {
const results = [{ text: 'Some fact' }];
expect(formatMemories(results)).toBe('- Some fact');
});
it('returns empty string for empty array', () => {
expect(formatMemories([])).toBe('');
});
it('separates entries with double newlines', () => {
const results = [{ text: 'A' }, { text: 'B' }];
expect(formatMemories(results)).toBe('- A\n\n- B');
});
});
describe('formatCurrentTime', () => {
it('returns UTC time in YYYY-MM-DD HH:MM format', () => {
const time = formatCurrentTime();
expect(time).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
});
describe('composeRecallQuery', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'What is my name?' },
];
it('returns latest query when contextTurns <= 1', () => {
expect(composeRecallQuery('What is my name?', messages, 1)).toBe('What is my name?');
});
it('returns latest query when messages empty', () => {
expect(composeRecallQuery('query', [], 3)).toBe('query');
});
it('includes prior context when contextTurns > 1', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
expect(result).toContain('Prior context:');
expect(result).toContain('user: Hello');
expect(result).toContain('assistant: Hi there');
expect(result).toContain('What is my name?');
});
it('does not duplicate latest query in context', () => {
const result = composeRecallQuery('What is my name?', messages, 3);
// "What is my name?" should appear once at the end, not also as "user: What is my name?"
const matches = result.match(/What is my name\?/g);
expect(matches?.length).toBe(1);
});
});
describe('truncateRecallQuery', () => {
it('returns query unchanged if within limit', () => {
expect(truncateRecallQuery('short', 'short', 100)).toBe('short');
});
it('truncates to latest when no prior context', () => {
const latest = 'my query';
expect(truncateRecallQuery(latest, latest, 5)).toBe('my qu');
});
it('drops oldest context lines first', () => {
const query = 'Prior context:\n\nuser: old\nassistant: older\nuser: recent\n\nlatest';
const result = truncateRecallQuery(query, 'latest', 50);
expect(result).toContain('latest');
// Should have dropped some old context
expect(result.length).toBeLessThanOrEqual(50);
});
});
describe('sliceLastTurnsByUserBoundary', () => {
const messages = [
{ role: 'user', content: 'A' },
{ role: 'assistant', content: 'B' },
{ role: 'user', content: 'C' },
{ role: 'assistant', content: 'D' },
{ role: 'user', content: 'E' },
];
it('returns last N turns', () => {
const result = sliceLastTurnsByUserBoundary(messages, 2);
expect(result.length).toBe(3); // user:C, assistant:D, user:E
expect(result[0].content).toBe('C');
});
it('returns all messages if turns > available', () => {
const result = sliceLastTurnsByUserBoundary(messages, 10);
expect(result.length).toBe(5);
});
it('returns empty for zero turns', () => {
expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]);
});
it('returns empty for empty messages', () => {
expect(sliceLastTurnsByUserBoundary([], 2)).toEqual([]);
});
});
describe('prepareRetentionTranscript', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
{ role: 'assistant', content: 'I am doing well' },
];
it('retains last turn by default', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages);
expect(messageCount).toBe(2);
expect(transcript).toContain('[role: user]');
expect(transcript).toContain('How are you?');
expect(transcript).toContain('I am doing well');
expect(transcript).not.toContain('Hello');
});
it('retains full window when requested', () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
expect(messageCount).toBe(4);
expect(transcript).toContain('Hello');
expect(transcript).toContain('How are you?');
});
it('returns null for empty messages', () => {
const { transcript, messageCount } = prepareRetentionTranscript([]);
expect(transcript).toBeNull();
expect(messageCount).toBe(0);
});
it('strips memory tags from content', () => {
const msgs = [
{ role: 'user', content: 'Query <hindsight_memories>data</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript } = prepareRetentionTranscript(msgs);
expect(transcript).not.toContain('hindsight_memories');
expect(transcript).toContain('Query');
});
it('skips messages with empty content after stripping', () => {
const msgs = [
{ role: 'user', content: '<hindsight_memories>only tags</hindsight_memories>' },
{ role: 'assistant', content: 'Response' },
];
const { transcript, messageCount } = prepareRetentionTranscript(msgs, true);
expect(messageCount).toBe(1); // only assistant message
expect(transcript).toContain('Response');
});
});
@@ -0,0 +1,178 @@
/**
* Content processing utilities.
*
* Port of the Claude Code plugin's content.py:
* - Memory tag stripping (anti-feedback-loop)
* - Recall query composition and truncation
* - Memory formatting for context injection
* - Retention transcript formatting
*/
/** Strip <hindsight_memories> and <relevant_memories> blocks to prevent retain feedback loops. */
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;
}
export interface RecallResult {
text: string;
type?: string | null;
mentioned_at?: string | null;
}
/** Format recall results into human-readable text for context injection. */
export function formatMemories(results: RecallResult[]): string {
if (!results.length) return '';
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : '';
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : '';
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
}
/** Format current UTC time for recall context. */
export function formatCurrentTime(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const h = String(now.getUTCHours()).padStart(2, '0');
const min = String(now.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
export interface Message {
role: string;
content: string;
}
/**
* Compose a multi-turn recall query from conversation history.
*
* When recallContextTurns > 1, includes prior context above the latest query.
*/
export function composeRecallQuery(
latestQuery: string,
messages: Message[],
recallContextTurns: number,
): string {
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !messages.length) return latest;
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines: string[] = [];
for (const msg of contextual) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
if (msg.role === 'user' && content === latest) continue;
contextLines.push(`${msg.role}: ${content}`);
}
if (!contextLines.length) return latest;
return ['Prior context:', contextLines.join('\n'), latest].join('\n\n');
}
/**
* Truncate a composed recall query to maxChars.
* Preserves the latest user message, drops oldest context lines first.
*/
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
if (maxChars <= 0 || query.length <= maxChars) return query;
const latest = latestQuery.trim();
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
if (!query.includes('Prior context:')) return latestOnly;
const contextMarker = 'Prior context:\n\n';
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) return latestOnly;
const suffix = '\n\n' + latest;
const suffixIndex = query.lastIndexOf(suffix);
if (suffixIndex === -1) return latestOnly;
if (suffix.length >= maxChars) return latestOnly;
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split('\n').filter(Boolean);
const kept: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
kept.unshift(contextLines[i]);
const candidate = `${contextMarker}${kept.join('\n')}${suffix}`;
if (candidate.length > maxChars) {
kept.shift();
break;
}
}
if (kept.length) return `${contextMarker}${kept.join('\n')}${suffix}`;
return latestOnly;
}
/** Slice messages to the last N turns, where a turn starts at a user message. */
export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number): Message[] {
if (!messages.length || turns <= 0) return [];
let userTurnsSeen = 0;
let startIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userTurnsSeen++;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
}
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
}
/**
* Format messages into a retention transcript.
*
* Uses [role: ...]...[role:end] markers for structured retention.
*/
export function prepareRetentionTranscript(
messages: Message[],
retainFullWindow: boolean = false,
): { transcript: string | null; messageCount: number } {
if (!messages.length) return { transcript: null, messageCount: 0 };
let targetMessages: Message[];
if (retainFullWindow) {
targetMessages = messages;
} else {
// Default: retain only the last turn
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
targetMessages = messages.slice(lastUserIdx);
}
const parts: string[] = [];
for (const msg of targetMessages) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}
if (!parts.length) return { transcript: null, messageCount: 0 };
const transcript = parts.join('\n\n');
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
return { transcript, messageCount: parts.length };
}
@@ -0,0 +1,357 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHooks, type PluginState } from './hooks.js';
import { makeConfig } from './test-helpers.js';
function makeState(): PluginState {
return {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
}
function makeClient() {
return {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: '' }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
}
function makeOpencodeClient(messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }> = []) {
return {
session: {
messages: vi.fn().mockResolvedValue({ data: messages }),
},
};
}
describe('createHooks', () => {
it('returns all required hooks', () => {
const hooks = createHooks(makeClient(), 'bank', makeConfig(), makeState(), makeOpencodeClient());
expect(hooks.event).toBeDefined();
expect(hooks['experimental.session.compacting']).toBeDefined();
expect(hooks['experimental.chat.system.transform']).toBeDefined();
});
});
describe('event hook — session.idle', () => {
it('auto-retains conversation on session.idle with document_id', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi there' }] },
];
const opencodeClient = makeOpencodeClient(messages);
const state = makeState();
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), state, opencodeClient);
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).toHaveBeenCalledTimes(1);
expect(client.retain.mock.calls[0][0]).toBe('bank');
// Full-session mode uses session ID as document_id
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
});
it('skips retain when autoRetain is false', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRetain: false }),
makeState(),
makeOpencodeClient(messages),
);
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).not.toHaveBeenCalled();
});
it('uses chunked document_id with overlap in last-turn mode', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Turn 1' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Reply 1' }] },
{ role: 'user', parts: [{ type: 'text', text: 'Turn 2' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Reply 2' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1, retainOverlapTurns: 1 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
// Chunked mode uses session-timestamp format
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it('respects retainEveryNTurns', async () => {
const client = makeClient();
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainEveryNTurns: 5 });
const state = makeState();
const hooks = createHooks(client, 'bank', config, state, makeOpencodeClient(messages));
await hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
});
// Only 1 user turn, needs 5 — should not retain
expect(client.retain).not.toHaveBeenCalled();
});
it('does not throw on client error', async () => {
const client = makeClient();
client.retain.mockRejectedValue(new Error('Network error'));
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const hooks = createHooks(client, 'bank', makeConfig({ retainEveryNTurns: 1 }), makeState(), makeOpencodeClient(messages));
await expect(
hooks.event({
event: { type: 'session.idle', properties: { sessionID: 'sess-1' } },
}),
).resolves.not.toThrow();
});
});
describe('event hook — session.created', () => {
it('tracks session for recall injection', async () => {
const state = makeState();
const hooks = createHooks(makeClient(), 'bank', makeConfig(), state, makeOpencodeClient());
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1', title: 'Test' } },
},
});
expect(state.recalledSessions.has('sess-1')).toBe(true);
});
it('does not track when autoRecall is false', async () => {
const state = makeState();
const hooks = createHooks(
makeClient(),
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
await hooks.event({
event: {
type: 'session.created',
properties: { info: { id: 'sess-1' } },
},
});
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
});
describe('compacting hook', () => {
it('retains before compaction and recalls context', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'Important fact', type: 'world' }],
});
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Build the feature' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Working on it' }] },
];
const output = { context: [] as string[], prompt: undefined };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
// Should have retained and recalled
expect(client.retain).toHaveBeenCalled();
expect(client.recall).toHaveBeenCalled();
expect(output.context.length).toBeGreaterThan(0);
expect(output.context[0]).toContain('hindsight_memories');
expect(output.context[0]).toContain('Important fact');
});
it('pre-compaction retain includes documentId and session metadata', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
expect(client.retain).toHaveBeenCalledTimes(1);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toBe('sess-1');
expect(opts.metadata.session_id).toBe('sess-1');
});
it('pre-compaction retain uses chunked documentId in last-turn mode', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] },
];
const config = makeConfig({ retainMode: 'last-turn', retainEveryNTurns: 1 });
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', config, makeState(), makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
const opts = client.retain.mock.calls[0][2];
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it('does not throw on error', async () => {
const client = makeClient();
client.recall.mockRejectedValue(new Error('Failed'));
const messages = [
{ role: 'user', parts: [{ type: 'text', text: 'Test' }] },
];
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), makeState(), makeOpencodeClient(messages));
await expect(
hooks['experimental.session.compacting']({ sessionID: 's' }, output),
).resolves.not.toThrow();
});
});
describe('system transform hook', () => {
it('injects memories for tracked sessions', async () => {
const client = makeClient();
client.recall.mockResolvedValue({
results: [{ text: 'User is a developer', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBeGreaterThan(0);
expect(output.system[0]).toContain('hindsight_memories');
// Session should be removed after first injection
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('skips untracked sessions', async () => {
const client = makeClient();
const state = makeState();
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-unknown', model: {} },
output,
);
expect(output.system.length).toBe(0);
expect(client.recall).not.toHaveBeenCalled();
});
it('consumes session on empty recall (no repeated queries for empty banks)', async () => {
const client = makeClient();
// No results — empty bank
client.recall.mockResolvedValue({ results: [] });
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
// No injection, but session consumed — won't re-query on next transform
expect(output.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('retries recall on next transform after transient API failure', async () => {
const client = makeClient();
// First call: API error (transient)
client.recall.mockRejectedValueOnce(new Error('Connection refused'));
// Second call: succeeds
client.recall.mockResolvedValueOnce({
results: [{ text: 'Found it', type: 'world' }],
});
const state = makeState();
state.recalledSessions.add('sess-1');
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient());
// First attempt — API error, session preserved for retry
const output1 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output1,
);
expect(output1.system.length).toBe(0);
expect(state.recalledSessions.has('sess-1')).toBe(true);
// Second attempt — succeeds, session consumed
const output2 = { system: [] as string[] };
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output2,
);
expect(output2.system.length).toBeGreaterThan(0);
expect(state.recalledSessions.has('sess-1')).toBe(false);
});
it('skips when autoRecall is false', async () => {
const client = makeClient();
const state = makeState();
state.recalledSessions.add('sess-1');
const output = { system: [] as string[] };
const hooks = createHooks(
client,
'bank',
makeConfig({ autoRecall: false }),
state,
makeOpencodeClient(),
);
await hooks['experimental.chat.system.transform'](
{ sessionID: 'sess-1', model: {} },
output,
);
expect(output.system.length).toBe(0);
});
});
@@ -0,0 +1,308 @@
/**
* Hook implementations for the Hindsight OpenCode plugin.
*
* Hooks:
* - event (session.created) → recall memories and inject into system prompt
* - event (session.idle) → auto-retain conversation transcript
* - experimental.session.compacting → inject memories into compaction context
*/
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import type { HindsightConfig } from './config.js';
import { debugLog } from './config.js';
import {
formatMemories,
formatCurrentTime,
stripMemoryTags,
composeRecallQuery,
truncateRecallQuery,
prepareRetentionTranscript,
sliceLastTurnsByUserBoundary,
type Message,
} from './content.js';
import { ensureBankMission } from './bank.js';
export interface PluginState {
turnCount: number;
missionsSet: Set<string>;
/** Track sessions we've already injected recall into */
recalledSessions: Set<string>;
/** Track last retained turn count per session to avoid duplicates */
lastRetainedTurn: Map<string, number>;
}
interface EventInput {
event: {
type: string;
properties: Record<string, unknown>;
};
}
interface CompactingInput {
sessionID: string;
}
interface CompactingOutput {
context: string[];
prompt?: string;
}
interface SystemTransformInput {
sessionID?: string;
model: unknown;
}
interface SystemTransformOutput {
system: string[];
}
type OpencodeClient = {
session: {
messages: (opts: { path: { id: string } }) => Promise<{ data?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> }>;
};
};
export interface HindsightHooks {
event: (input: EventInput) => Promise<void>;
'experimental.session.compacting': (
input: CompactingInput,
output: CompactingOutput,
) => Promise<void>;
'experimental.chat.system.transform': (
input: SystemTransformInput,
output: SystemTransformOutput,
) => Promise<void>;
}
export function createHooks(
hindsightClient: HindsightClient,
bankId: string,
config: HindsightConfig,
state: PluginState,
opencodeClient: OpencodeClient,
): HindsightHooks {
interface RecallOutcome {
/** formatted context string, or null if no results */
context: string | null;
/** true if the API call succeeded (even with 0 results) */
ok: boolean;
}
/** Recall memories and format as context string */
async function recallForContext(query: string): Promise<RecallOutcome> {
try {
const response = await hindsightClient.recall(bankId, query, {
budget: config.recallBudget as 'low' | 'mid' | 'high',
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
});
const results = response.results || [];
if (!results.length) return { context: null, ok: true };
const formatted = formatMemories(results);
const context =
`<hindsight_memories>\n` +
`${config.recallPromptPreamble}\n` +
`Current time: ${formatCurrentTime()} UTC\n\n` +
`${formatted}\n` +
`</hindsight_memories>`;
return { context, ok: true };
} catch (e) {
debugLog(config, 'Recall failed:', e);
return { context: null, ok: false };
}
}
/** Extract plain-text messages from an OpenCode session */
async function getSessionMessages(sessionId: string): Promise<Message[]> {
try {
const response = await opencodeClient.session.messages({
path: { id: sessionId },
});
const rawMessages = response.data || [];
const messages: Message[] = [];
for (const msg of rawMessages) {
const role = msg.role;
if (role !== 'user' && role !== 'assistant') continue;
const textParts = (msg.parts || [])
.filter((p: { type: string; text?: string }) => p.type === 'text' && p.text)
.map((p: { type: string; text?: string }) => p.text!);
if (textParts.length) {
messages.push({ role, content: textParts.join('\n') });
}
}
return messages;
} catch (e) {
debugLog(config, 'Failed to get session messages:', e);
return [];
}
}
/**
* Retain messages for a session, respecting retainMode and documentId semantics.
* Used by both idle-retain and pre-compaction retain.
*/
async function retainSession(sessionId: string, messages: Message[]): Promise<void> {
const retainFullWindow = config.retainMode === 'full-session';
let targetMessages: Message[];
let documentId: string;
if (retainFullWindow) {
targetMessages = messages;
// Full-session upserts the same document each time
documentId = sessionId;
} else {
// Sliding window: retainEveryNTurns + overlap
const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns;
targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns);
// Chunked mode: unique document per chunk
documentId = `${sessionId}-${Date.now()}`;
}
const { transcript } = prepareRetentionTranscript(targetMessages, true);
if (!transcript) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
await hindsightClient.retain(bankId, transcript, {
documentId,
context: config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? { ...config.retainMetadata, session_id: sessionId }
: { session_id: sessionId },
async: true,
});
}
/** Auto-retain conversation transcript */
async function handleSessionIdle(sessionId: string): Promise<void> {
if (!config.autoRetain) return;
const messages = await getSessionMessages(sessionId);
if (!messages.length) return;
// Count user turns
const userTurns = messages.filter((m) => m.role === 'user').length;
const lastRetained = state.lastRetainedTurn.get(sessionId) || 0;
// Only retain if enough new turns since last retain
if (userTurns - lastRetained < config.retainEveryNTurns) return;
try {
await retainSession(sessionId, messages);
state.lastRetainedTurn.set(sessionId, userTurns);
debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`);
} catch (e) {
debugLog(config, 'Auto-retain failed:', e);
}
}
const event = async (input: EventInput): Promise<void> => {
try {
const { event: evt } = input;
if (evt.type === 'session.idle') {
const sessionId = (evt.properties as { sessionID?: string }).sessionID;
if (sessionId) {
await handleSessionIdle(sessionId);
}
}
if (evt.type === 'session.created') {
const session = evt.properties.info as { id?: string; title?: string } | undefined;
const sessionId = session?.id;
if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) {
state.recalledSessions.add(sessionId);
// Cap tracked sessions
if (state.recalledSessions.size > 1000) {
const first = state.recalledSessions.values().next().value;
if (first) state.recalledSessions.delete(first);
}
}
}
} catch (e) {
debugLog(config, 'Event hook error:', e);
}
};
const compacting = async (
input: CompactingInput,
output: CompactingOutput,
): Promise<void> => {
try {
// First, retain what we have before compaction (using shared retention logic)
const messages = await getSessionMessages(input.sessionID);
if (messages.length && config.autoRetain) {
try {
await retainSession(input.sessionID, messages);
debugLog(config, 'Pre-compaction retain completed');
} catch (e) {
debugLog(config, 'Pre-compaction retain failed:', e);
}
}
// Then recall relevant memories to inject into compaction context
if (messages.length) {
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserMsg) {
const query = composeRecallQuery(
lastUserMsg.content,
messages,
config.recallContextTurns,
);
const truncated = truncateRecallQuery(
query,
lastUserMsg.content,
config.recallMaxQueryChars,
);
const { context } = await recallForContext(truncated);
if (context) {
output.context.push(context);
}
}
}
} catch (e) {
debugLog(config, 'Compaction hook error:', e);
}
};
const systemTransform = async (
input: SystemTransformInput,
output: SystemTransformOutput,
): Promise<void> => {
try {
if (!config.autoRecall) return;
const sessionId = input.sessionID;
if (!sessionId) return;
// Only inject on first message of a session (tracked by recalledSessions)
if (!state.recalledSessions.has(sessionId)) return;
await ensureBankMission(hindsightClient, bankId, config, state.missionsSet);
// Use a generic project-context query for session start
const query = `project context and recent work`;
const { context, ok } = await recallForContext(query);
// Consume after a successful API round-trip (even with 0 results).
// Only preserve retry for transient API failures (ok=false).
if (ok) {
state.recalledSessions.delete(sessionId);
}
if (context) {
output.system.push(context);
debugLog(config, `Injected recall context for session ${sessionId}`);
}
} catch (e) {
debugLog(config, 'System transform hook error:', e);
}
};
return {
event,
'experimental.session.compacting': compacting,
'experimental.chat.system.transform': systemTransform,
};
}
@@ -0,0 +1,80 @@
/**
* Hindsight OpenCode Plugin — persistent long-term memory for OpenCode agents.
*
* Provides:
* - Custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
* - Auto-retain on session.idle
* - Memory injection on session.created via system transform
* - Memory preservation during context compaction
*
* @example
* ```json
* // opencode.json
* { "plugin": ["@vectorize-io/opencode-hindsight"] }
*
* // With options:
* { "plugin": [["@vectorize-io/opencode-hindsight", { "bankId": "my-bank" }]] }
* ```
*/
import type { Plugin, PluginModule } from '@opencode-ai/plugin';
import { HindsightClient } from '@vectorize-io/hindsight-client';
import { loadConfig } from './config.js';
import { deriveBankId } from './bank.js';
import { createTools } from './tools.js';
import { createHooks, type PluginState } from './hooks.js';
import { debugLog } from './config.js';
const HindsightPlugin: Plugin = async (input, options) => {
const config = loadConfig(options);
const apiUrl = config.hindsightApiUrl;
if (!apiUrl) {
console.error(
'[Hindsight] No API URL configured. Set HINDSIGHT_API_URL environment variable ' +
'or add hindsightApiUrl to ~/.hindsight/opencode.json',
);
// Return empty hooks — graceful degradation
return {};
}
const client = new HindsightClient({
baseUrl: apiUrl,
apiKey: config.hindsightApiToken || undefined,
});
const bankId = deriveBankId(config, input.directory);
debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`);
const state: PluginState = {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
const tools = createTools(client, bankId, config, state.missionsSet);
const hooks = createHooks(client, bankId, config, state, input.client as unknown as Parameters<typeof createHooks>[4]);
return {
tool: tools,
...hooks,
};
};
// Named export for direct import
export { HindsightPlugin };
// Default export as PluginModule for OpenCode plugin loader
const module: PluginModule = {
id: 'hindsight',
server: HindsightPlugin,
};
export default module;
// Re-export types for consumers
export type { HindsightConfig } from './config.js';
export type { PluginState } from './hooks.js';
export { loadConfig } from './config.js';
export { deriveBankId } from './bank.js';
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the HindsightClient before importing the plugin
vi.mock('@vectorize-io/hindsight-client', () => {
const MockHindsightClient = vi.fn(function (this: any) {
this.retain = vi.fn().mockResolvedValue({});
this.recall = vi.fn().mockResolvedValue({ results: [] });
this.reflect = vi.fn().mockResolvedValue({ text: '' });
this.createBank = vi.fn().mockResolvedValue({});
});
return { HindsightClient: MockHindsightClient };
});
import { HindsightPlugin } from './index.js';
import { HindsightClient } from '@vectorize-io/hindsight-client';
const mockPluginInput = {
client: {
session: {
messages: vi.fn().mockResolvedValue({ data: [] }),
},
},
project: { id: 'test-project', worktree: '/tmp/test', vcs: 'git' },
directory: '/tmp/test-project',
worktree: '/tmp/test-project',
serverUrl: new URL('http://localhost:3000'),
$: {} as any,
};
describe('HindsightPlugin', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) delete process.env[key];
}
vi.clearAllMocks();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('returns empty hooks when no API URL configured', async () => {
const result = await HindsightPlugin(mockPluginInput as any);
expect(result).toEqual({});
expect(HindsightClient).not.toHaveBeenCalled();
});
it('returns tools and hooks when configured', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
const result = await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:8888',
apiKey: undefined,
});
expect(result.tool).toBeDefined();
expect(result.tool!.hindsight_retain).toBeDefined();
expect(result.tool!.hindsight_recall).toBeDefined();
expect(result.tool!.hindsight_reflect).toBeDefined();
expect(result.event).toBeDefined();
expect(result['experimental.session.compacting']).toBeDefined();
expect(result['experimental.chat.system.transform']).toBeDefined();
});
it('passes API key when configured', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
process.env.HINDSIGHT_API_TOKEN = 'my-token';
await HindsightPlugin(mockPluginInput as any);
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://localhost:8888',
apiKey: 'my-token',
});
});
it('accepts plugin options', async () => {
const result = await HindsightPlugin(mockPluginInput as any, {
hindsightApiUrl: 'http://example.com',
bankId: 'custom-bank',
});
expect(result.tool).toBeDefined();
expect(HindsightClient).toHaveBeenCalledWith({
baseUrl: 'http://example.com',
apiKey: undefined,
});
});
});
describe('PluginModule default export', () => {
it('exports correct module shape', async () => {
const mod = await import('./index.js');
expect(mod.default).toBeDefined();
expect(mod.default.id).toBe('hindsight');
expect(typeof mod.default.server).toBe('function');
});
});
@@ -0,0 +1,31 @@
import type { HindsightConfig } from './config.js';
export function makeConfig(overrides: Partial<HindsightConfig> = {}): HindsightConfig {
return {
autoRecall: true,
recallBudget: 'mid',
recallMaxTokens: 1024,
recallTypes: ['world', 'experience'],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallPromptPreamble: '',
autoRetain: true,
retainMode: 'full-session',
retainEveryNTurns: 10,
retainOverlapTurns: 2,
retainContext: 'opencode',
retainTags: [],
retainMetadata: {},
hindsightApiUrl: null,
hindsightApiToken: null,
bankId: null,
bankIdPrefix: '',
dynamicBankId: false,
dynamicBankGranularity: ['agent', 'project'],
bankMission: '',
retainMission: null,
agentName: 'opencode',
debug: false,
...overrides,
};
}
@@ -0,0 +1,318 @@
import { describe, it, expect, vi } from 'vitest';
import { createTools } from './tools.js';
import { makeConfig } from './test-helpers.js';
const mockContext = {
sessionID: 'sess-1',
messageID: 'msg-1',
agent: 'default',
directory: '/tmp',
worktree: '/tmp',
abort: new AbortController().signal,
metadata: vi.fn(),
ask: vi.fn(),
};
describe('createTools', () => {
it('creates all three tools', () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
expect(tools.hindsight_retain).toBeDefined();
expect(tools.hindsight_recall).toBeDefined();
expect(tools.hindsight_reflect).toBeDefined();
});
it('all tools have description and execute', () => {
const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
for (const tool of Object.values(tools)) {
expect(tool.description).toBeTruthy();
expect(typeof tool.execute).toBe('function');
}
});
describe('hindsight_retain', () => {
it('calls client.retain with correct bank and content', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_retain.execute(
{ content: 'User likes TypeScript' },
mockContext,
);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'User likes TypeScript', {
context: 'opencode',
tags: undefined,
metadata: undefined,
});
expect(result).toBe('Memory stored successfully.');
});
it('passes optional context', async () => {
const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any;
const tools = createTools(client, 'test-bank', makeConfig());
await tools.hindsight_retain.execute(
{ content: 'Fact', context: 'from conversation' },
mockContext,
);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', {
context: 'from conversation',
tags: undefined,
metadata: undefined,
});
});
it('includes tags and metadata from config', async () => {
const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any;
const config = makeConfig({
retainTags: ['coding'],
retainMetadata: { source: 'opencode' },
});
const tools = createTools(client, 'test-bank', config);
await tools.hindsight_retain.execute({ content: 'Fact' }, mockContext);
expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', {
context: 'opencode',
tags: ['coding'],
metadata: { source: 'opencode' },
});
});
});
describe('hindsight_recall', () => {
it('calls client.recall and formats results', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({
results: [
{ text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' },
],
}),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_recall.execute(
{ query: 'user preferences' },
mockContext,
);
expect(client.recall).toHaveBeenCalledWith('test-bank', 'user preferences', {
budget: 'mid',
maxTokens: 1024,
types: ['world', 'experience'],
});
expect(result).toContain('User likes Python');
expect(result).toContain('[world]');
});
it('returns no-results message when empty', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_recall.execute({ query: 'unknown' }, mockContext);
expect(result).toBe('No relevant memories found.');
});
it('uses config budget settings', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn(),
} as any;
const config = makeConfig({ recallBudget: 'high', recallMaxTokens: 4096 });
const tools = createTools(client, 'test-bank', config);
await tools.hindsight_recall.execute({ query: 'test' }, mockContext);
expect(client.recall).toHaveBeenCalledWith('test-bank', 'test', {
budget: 'high',
maxTokens: 4096,
types: ['world', 'experience'],
});
});
});
describe('hindsight_reflect', () => {
it('calls client.reflect and returns text', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'The user is a Python developer.' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_reflect.execute(
{ query: 'What do I know about this user?' },
mockContext,
);
expect(client.reflect).toHaveBeenCalledWith(
'test-bank',
'What do I know about this user?',
{ context: undefined, budget: 'mid' },
);
expect(result).toBe('The user is a Python developer.');
});
it('returns fallback when no text', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: '' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
const result = await tools.hindsight_reflect.execute(
{ query: 'something' },
mockContext,
);
expect(result).toBe('No relevant information found to reflect on.');
});
it('passes context to reflect', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'Answer' }),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await tools.hindsight_reflect.execute(
{ query: 'Q', context: 'We are building an app' },
mockContext,
);
expect(client.reflect).toHaveBeenCalledWith('test-bank', 'Q', {
context: 'We are building an app',
budget: 'mid',
});
});
});
describe('error propagation', () => {
it('propagates retain errors', async () => {
const client = {
retain: vi.fn().mockRejectedValue(new Error('Network error')),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await expect(
tools.hindsight_retain.execute({ content: 'test' }, mockContext),
).rejects.toThrow('Network error');
});
it('propagates recall errors', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn().mockRejectedValue(new Error('Timeout')),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await expect(
tools.hindsight_recall.execute({ query: 'test' }, mockContext),
).rejects.toThrow('Timeout');
});
it('propagates reflect errors', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockRejectedValue(new Error('Server error')),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await expect(
tools.hindsight_reflect.execute({ query: 'test' }, mockContext),
).rejects.toThrow('Server error');
});
});
describe('bank mission setup', () => {
it('calls ensureBankMission before retain when missionsSet provided', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Extract technical decisions' });
const tools = createTools(client, 'test-bank', config, missionsSet);
await tools.hindsight_retain.execute({ content: 'fact' }, mockContext);
expect(client.createBank).toHaveBeenCalledWith('test-bank', {
reflectMission: 'Extract technical decisions',
retainMission: undefined,
});
expect(missionsSet.has('test-bank')).toBe(true);
expect(client.retain).toHaveBeenCalled();
});
it('calls ensureBankMission before reflect when missionsSet provided', async () => {
const client = {
retain: vi.fn(),
recall: vi.fn(),
reflect: vi.fn().mockResolvedValue({ text: 'answer' }),
createBank: vi.fn().mockResolvedValue({}),
} as any;
const missionsSet = new Set<string>();
const config = makeConfig({ bankMission: 'Synthesize project context' });
const tools = createTools(client, 'test-bank', config, missionsSet);
await tools.hindsight_reflect.execute({ query: 'summary' }, mockContext);
expect(client.createBank).toHaveBeenCalled();
expect(client.reflect).toHaveBeenCalled();
});
it('skips mission setup when missionsSet not provided (backward compat)', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn(),
reflect: vi.fn(),
} as any;
const tools = createTools(client, 'test-bank', makeConfig());
await tools.hindsight_retain.execute({ content: 'fact' }, mockContext);
expect(client.retain).toHaveBeenCalled();
// No createBank call since missionsSet wasn't passed
});
});
it('always uses constructor bankId', async () => {
const client = {
retain: vi.fn().mockResolvedValue({}),
recall: vi.fn().mockResolvedValue({ results: [] }),
reflect: vi.fn().mockResolvedValue({ text: 'ok' }),
} as any;
const tools = createTools(client, 'fixed-bank', makeConfig());
await tools.hindsight_retain.execute({ content: 'x' }, mockContext);
await tools.hindsight_recall.execute({ query: 'x' }, mockContext);
await tools.hindsight_reflect.execute({ query: 'x' }, mockContext);
expect(client.retain.mock.calls[0][0]).toBe('fixed-bank');
expect(client.recall.mock.calls[0][0]).toBe('fixed-bank');
expect(client.reflect.mock.calls[0][0]).toBe('fixed-bank');
});
});
@@ -0,0 +1,109 @@
/**
* Custom tool definitions for the Hindsight OpenCode plugin.
*
* Registers hindsight_retain, hindsight_recall, and hindsight_reflect
* as tools the agent can call explicitly.
*/
import { tool } from '@opencode-ai/plugin/tool';
import type { ToolDefinition } from '@opencode-ai/plugin/tool';
import type { HindsightClient } from '@vectorize-io/hindsight-client';
import type { HindsightConfig } from './config.js';
import { formatMemories, formatCurrentTime } from './content.js';
import { ensureBankMission } from './bank.js';
export interface HindsightTools {
hindsight_retain: ToolDefinition;
hindsight_recall: ToolDefinition;
hindsight_reflect: ToolDefinition;
}
export function createTools(
client: HindsightClient,
bankId: string,
config: HindsightConfig,
missionsSet?: Set<string>,
): HindsightTools {
const hindsight_retain = tool({
description:
'Store information in long-term memory. Use this to remember important facts, ' +
'user preferences, project context, decisions, and anything worth recalling in future sessions. ' +
'Be specific — include who, what, when, and why.',
args: {
content: tool.schema.string().describe(
'The information to remember. Be specific and self-contained.',
),
context: tool.schema
.string()
.optional()
.describe('Optional context about where this information came from.'),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
await client.retain(bankId, args.content, {
context: args.context || config.retainContext,
tags: config.retainTags.length ? config.retainTags : undefined,
metadata: Object.keys(config.retainMetadata).length
? config.retainMetadata
: undefined,
});
return 'Memory stored successfully.';
},
});
const hindsight_recall = tool({
description:
'Search long-term memory for relevant information. Use this proactively before ' +
'answering questions about past conversations, user preferences, project history, ' +
'or any topic where prior context would help. When in doubt, recall first.',
args: {
query: tool.schema.string().describe(
'Natural language search query. Be specific about what you need to know.',
),
},
async execute(args) {
const response = await client.recall(bankId, args.query, {
budget: config.recallBudget as 'low' | 'mid' | 'high',
maxTokens: config.recallMaxTokens,
types: config.recallTypes,
});
const results = response.results || [];
if (!results.length) return 'No relevant memories found.';
const formatted = formatMemories(results);
return `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`;
},
});
const hindsight_reflect = tool({
description:
'Generate a thoughtful answer using long-term memory. Unlike recall (which returns ' +
'raw memories), reflect synthesizes memories into a coherent answer. Use for questions ' +
'like "What do you know about this user?" or "Summarize our project decisions."',
args: {
query: tool.schema.string().describe(
'The question to answer using long-term memory.',
),
context: tool.schema
.string()
.optional()
.describe('Optional additional context to guide the reflection.'),
},
async execute(args) {
if (missionsSet) {
await ensureBankMission(client, bankId, config, missionsSet);
}
const response = await client.reflect(bankId, args.query, {
context: args.context,
budget: config.recallBudget as 'low' | 'mid' | 'high',
});
return response.text || 'No relevant information found to reflect on.';
},
});
return { hindsight_retain, hindsight_recall, hindsight_reflect };
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"lib": ["ES2022"],
"moduleResolution": "node16",
"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,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
});
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
+1 -1
View File
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip")
VALID_INTEGRATIONS=("litellm" "pydantic-ai" "crewai" "ag2" "ai-sdk" "chat" "openclaw" "langgraph" "llamaindex" "nemoclaw" "strands" "claude-code" "codex" "hermes" "autogen" "paperclip" "opencode")
usage() {
print_error "Usage: $0 <integration> <version>"