Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f978b446d | ||
|
|
a4a9d32480 | ||
|
|
8b4c7eeb8e | ||
|
|
311b96a192 | ||
|
|
fdc48b1544 | ||
|
|
ebb05cf9ca |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
+2686
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'],
|
||||
},
|
||||
});
|
||||
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user