feat: add OpenCode persistent memory plugin (#853)
* feat: add OpenCode persistent memory plugin
Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page
79 tests across 6 test files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address review findings for opencode integration
1. Pre-compaction retain now uses shared retainSession() helper,
respecting retainMode, documentId, and session_id metadata
consistently with idle-retain (was bypassing retention policy).
2. System transform recall is only consumed after successful injection.
If Hindsight is briefly unavailable, the plugin retries on the next
LLM call instead of permanently skipping recall for the session.
3. Config validation for retainMode and recallBudget — typos like
"full_session" or "maximum" now log a warning and fall back to
the default instead of silently changing retention semantics.
85 tests (6 new covering compaction documentId, recall retry, and
config validation).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: docs/tools findings from second review round
1. Remove "session" from supported dynamic bank fields in docs —
the implementation can't vary bank ID per session since it's
derived once at plugin startup.
2. Explicit tools (retain, reflect) now call ensureBankMission()
before API calls, so bankMission/retainMission are applied even
when the agent uses tools exclusively without triggering hooks.
3. Added tests for mission setup via tools path.
88 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: recall retry semantics and README bank scoping clarity
1. recallForContext now returns { context, ok } to distinguish
"no results" (ok=true) from "API error" (ok=false). System
transform consumes the session on ok=true even with 0 results,
so empty banks don't cause repeated queries. Only transient API
failures preserve retry.
2. README clarifies that channel/user bank dimensions are process-
scoped (set via env vars before launch), not per-session dynamic
within a running OpenCode process.
89 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: review fixes for opencode integration
- Rename CI job from build-opencode-integration to test-opencode-integration
to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files
* fix: remove unused PluginState import from tools.ts
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
This commit is contained in:
parent
66cbdda3cb
commit
e1c6220f0e
24 changed files with 5346 additions and 1 deletions
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
141
hindsight-docs/docs-integrations/opencode.md
Normal file
141
hindsight-docs/docs-integrations/opencode.md
Normal file
|
|
@ -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",
|
||||
|
|
|
|||
4
hindsight-docs/static/img/icons/opencode.svg
Normal file
4
hindsight-docs/static/img/icons/opencode.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#1a1a2e"/>
|
||||
<text x="32" y="42" font-family="monospace" font-size="28" font-weight="bold" fill="#00d4ff" text-anchor="middle">OC</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
144
hindsight-integrations/opencode/README.md
Normal file
144
hindsight-integrations/opencode/README.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# @vectorize-io/opencode-hindsight
|
||||
|
||||
Hindsight memory plugin for [OpenCode](https://opencode.ai) — give your AI coding agent persistent long-term memory across sessions.
|
||||
|
||||
## Features
|
||||
|
||||
- **Custom tools**: `hindsight_retain`, `hindsight_recall`, `hindsight_reflect` — the agent calls these explicitly
|
||||
- **Auto-retain**: Captures conversation on `session.idle` and stores to Hindsight
|
||||
- **Memory injection**: Recalls relevant memories when a new session starts
|
||||
- **Compaction hook**: Injects memories during context compaction so they survive window trimming
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/opencode-hindsight
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
Add to your `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["@vectorize-io/opencode-hindsight"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Set Environment Variables
|
||||
|
||||
```bash
|
||||
# Required: Hindsight API URL
|
||||
export HINDSIGHT_API_URL="http://localhost:8888"
|
||||
|
||||
# Optional: API key for Hindsight Cloud
|
||||
export HINDSIGHT_API_TOKEN="your-api-key"
|
||||
|
||||
# Optional: Override the memory bank ID
|
||||
export HINDSIGHT_BANK_ID="my-project"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Plugin Options
|
||||
|
||||
Pass options directly in `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
["@vectorize-io/opencode-hindsight", {
|
||||
"hindsightApiUrl": "http://localhost:8888",
|
||||
"bankId": "my-project",
|
||||
"autoRecall": true,
|
||||
"autoRetain": true,
|
||||
"recallBudget": "mid"
|
||||
}]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Config File
|
||||
|
||||
Create `~/.hindsight/opencode.json` for persistent configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"hindsightApiUrl": "http://localhost:8888",
|
||||
"hindsightApiToken": "your-api-key",
|
||||
"recallBudget": "mid",
|
||||
"retainEveryNTurns": 10,
|
||||
"debug": false
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `HINDSIGHT_API_URL` | Hindsight API base URL | (required) |
|
||||
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none) |
|
||||
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
|
||||
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
|
||||
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
|
||||
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
|
||||
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
|
||||
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
|
||||
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
|
||||
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
|
||||
| `HINDSIGHT_BANK_MISSION` | Bank mission/context | (none) |
|
||||
| `HINDSIGHT_DEBUG` | Enable debug logging | `false` |
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
Settings are loaded in this order (later wins):
|
||||
|
||||
1. Built-in defaults
|
||||
2. `~/.hindsight/opencode.json`
|
||||
3. Plugin options from `opencode.json`
|
||||
4. Environment variables
|
||||
|
||||
## Tools
|
||||
|
||||
### `hindsight_retain`
|
||||
|
||||
Store information in long-term memory. The agent uses this to save important facts, user preferences, project context, and decisions.
|
||||
|
||||
### `hindsight_recall`
|
||||
|
||||
Search long-term memory. The agent uses this proactively before answering questions where prior context would help.
|
||||
|
||||
### `hindsight_reflect`
|
||||
|
||||
Generate a synthesized answer from long-term memory. Unlike recall (raw memories), reflect produces a coherent summary.
|
||||
|
||||
## Dynamic Bank IDs
|
||||
|
||||
For multi-project setups, enable dynamic bank ID derivation:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_DYNAMIC_BANK_ID=true
|
||||
```
|
||||
|
||||
The bank ID is composed from granularity fields (default: `agent::project`). Supported fields: `agent`, `project`, `channel`, `user`.
|
||||
|
||||
**Note:** The bank ID is derived once when the plugin loads, from environment variables set before OpenCode starts. These dimensions are process-scoped — they don't change per session within a running OpenCode process. For per-user isolation, set the env vars before launching each user's OpenCode instance:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_CHANNEL_ID="slack-general"
|
||||
export HINDSIGHT_USER_ID="user123"
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test # Run tests
|
||||
npm run build # Build to dist/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
2686
hindsight-integrations/opencode/package-lock.json
generated
Normal file
2686
hindsight-integrations/opencode/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
63
hindsight-integrations/opencode/package.json
Normal file
63
hindsight-integrations/opencode/package.json
Normal file
|
|
@ -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 <support@vectorize.io>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
139
hindsight-integrations/opencode/src/bank.test.ts
Normal file
139
hindsight-integrations/opencode/src/bank.test.ts
Normal file
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
94
hindsight-integrations/opencode/src/bank.ts
Normal file
94
hindsight-integrations/opencode/src/bank.ts
Normal file
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
127
hindsight-integrations/opencode/src/config.test.ts
Normal file
127
hindsight-integrations/opencode/src/config.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
187
hindsight-integrations/opencode/src/config.ts
Normal file
187
hindsight-integrations/opencode/src/config.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
194
hindsight-integrations/opencode/src/content.test.ts
Normal file
194
hindsight-integrations/opencode/src/content.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
178
hindsight-integrations/opencode/src/content.ts
Normal file
178
hindsight-integrations/opencode/src/content.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
357
hindsight-integrations/opencode/src/hooks.test.ts
Normal file
357
hindsight-integrations/opencode/src/hooks.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
308
hindsight-integrations/opencode/src/hooks.ts
Normal file
308
hindsight-integrations/opencode/src/hooks.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
80
hindsight-integrations/opencode/src/index.ts
Normal file
80
hindsight-integrations/opencode/src/index.ts
Normal file
|
|
@ -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';
|
||||
102
hindsight-integrations/opencode/src/plugin.test.ts
Normal file
102
hindsight-integrations/opencode/src/plugin.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
31
hindsight-integrations/opencode/src/test-helpers.ts
Normal file
31
hindsight-integrations/opencode/src/test-helpers.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
318
hindsight-integrations/opencode/src/tools.test.ts
Normal file
318
hindsight-integrations/opencode/src/tools.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
109
hindsight-integrations/opencode/src/tools.ts
Normal file
109
hindsight-integrations/opencode/src/tools.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
18
hindsight-integrations/opencode/tsconfig.json
Normal file
18
hindsight-integrations/opencode/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
11
hindsight-integrations/opencode/tsup.config.ts
Normal file
11
hindsight-integrations/opencode/tsup.config.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
9
hindsight-integrations/opencode/vitest.config.ts
Normal file
9
hindsight-integrations/opencode/vitest.config.ts
Normal file
|
|
@ -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>"
|
||||
|
|
|
|||
Loading…
Reference in a new issue