Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71840dd23a | ||
|
|
8d7f05e1d3 | ||
|
|
f159fccbc0 | ||
|
|
0e0b2ae589 | ||
|
|
1ee5206363 | ||
|
|
7f5a8862d9 |
@@ -11,6 +11,8 @@ Hindsight memory plugin for [OpenCode](https://opencode.ai) — give your AI cod
|
||||
|
||||
## Quick Start
|
||||
|
||||
The plugin defaults to **Hindsight Cloud** (`https://api.hindsight.vectorize.io`). Just enable it and provide your API key.
|
||||
|
||||
### 1. Enable the plugin
|
||||
|
||||
Add to your `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
|
||||
@@ -24,23 +26,25 @@ Add to your `opencode.json` (project) or `~/.config/opencode/opencode.json` (glo
|
||||
|
||||
OpenCode auto-installs plugins listed here on startup — no `npm install` required.
|
||||
|
||||
### 2. Point to your Hindsight server
|
||||
### 2. Provide your Hindsight Cloud API key
|
||||
|
||||
Get an API key at [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect), then:
|
||||
|
||||
```bash
|
||||
# Self-hosted
|
||||
export HINDSIGHT_API_URL="http://localhost:8888"
|
||||
export HINDSIGHT_API_TOKEN="your-api-key"
|
||||
|
||||
# Optional: override the memory bank ID
|
||||
# Optional: override the memory bank ID (defaults to "opencode")
|
||||
export HINDSIGHT_BANK_ID="my-project"
|
||||
```
|
||||
|
||||
### Using Hindsight Cloud
|
||||
That's it — the plugin now reads/writes against your Cloud bank.
|
||||
|
||||
Get an API key at [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect), then either export env vars:
|
||||
### Using a self-hosted Hindsight instance
|
||||
|
||||
Point `HINDSIGHT_API_URL` at your server (the API key is then optional):
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_URL="https://api.hindsight.vectorize.io"
|
||||
export HINDSIGHT_API_TOKEN="your-api-key"
|
||||
export HINDSIGHT_API_URL="http://localhost:8888"
|
||||
```
|
||||
|
||||
Or configure inline in `opencode.json`:
|
||||
@@ -52,8 +56,7 @@ Or configure inline in `opencode.json`:
|
||||
[
|
||||
"@vectorize-io/opencode-hindsight",
|
||||
{
|
||||
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
|
||||
"hindsightApiToken": "your-api-key"
|
||||
"hindsightApiUrl": "http://localhost:8888"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -99,20 +102,20 @@ Create `~/.hindsight/opencode.json` for persistent configuration:
|
||||
|
||||
### 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` |
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------- | ----------------------------------- | ------------------------------------- |
|
||||
| `HINDSIGHT_API_URL` | Hindsight API base URL | `https://api.hindsight.vectorize.io` |
|
||||
| `HINDSIGHT_API_TOKEN` | API key for authentication | (none — required for Hindsight Cloud) |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vectorize-io/opencode-hindsight",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Hindsight memory plugin for OpenCode - Give your AI coding agent persistent long-term memory",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -38,6 +38,7 @@
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "HINDSIGHT_LIVE_E2E=1 vitest run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { loadConfig, type HindsightConfig } from "./config.js";
|
||||
import { loadConfig, DEFAULT_HINDSIGHT_API_URL, type HindsightConfig } from "./config.js";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
@@ -27,7 +27,7 @@ describe("loadConfig", () => {
|
||||
expect(config.agentName).toBe("opencode");
|
||||
expect(config.dynamicBankId).toBe(false);
|
||||
expect(config.debug).toBe(false);
|
||||
expect(config.hindsightApiUrl).toBeNull();
|
||||
expect(config.hindsightApiUrl).toBe(DEFAULT_HINDSIGHT_API_URL);
|
||||
expect(config.hindsightApiToken).toBeNull();
|
||||
expect(config.bankId).toBeNull();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/** Default API URL used when no override is supplied via env, file, or plugin options. */
|
||||
export const DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io";
|
||||
|
||||
export interface HindsightConfig {
|
||||
// Recall
|
||||
autoRecall: boolean;
|
||||
@@ -75,7 +78,7 @@ const DEFAULTS: HindsightConfig = {
|
||||
retainMetadata: {},
|
||||
|
||||
// Connection
|
||||
hindsightApiUrl: null,
|
||||
hindsightApiUrl: DEFAULT_HINDSIGHT_API_URL,
|
||||
hindsightApiToken: null,
|
||||
|
||||
// Bank
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Gated live end-to-end test for the OpenCode Hindsight plugin.
|
||||
*
|
||||
* Drives the plugin's exported tools + hooks against a live Hindsight server
|
||||
* the way the OpenCode runtime would. Skipped by default; runs only when
|
||||
* HINDSIGHT_LIVE_E2E=1 (analogous to the `requires_real_llm` pytest marker
|
||||
* used by the Python integrations).
|
||||
*
|
||||
* Run with:
|
||||
* HINDSIGHT_API_URL=http://127.0.0.1:8888 npm run test:e2e
|
||||
*
|
||||
* Requires:
|
||||
* - A reachable Hindsight server (defaults to http://127.0.0.1:8888).
|
||||
* - When pointing at the hosted backend: HINDSIGHT_API_TOKEN with a valid key.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, it, expect } from "vitest";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { HindsightPlugin } from "./index.js";
|
||||
import { HindsightClient } from "@vectorize-io/hindsight-client";
|
||||
|
||||
const LIVE = process.env.HINDSIGHT_LIVE_E2E === "1";
|
||||
const URL = process.env.HINDSIGHT_API_URL || "http://127.0.0.1:8888";
|
||||
const BANK = `e2e-opencode-${randomBytes(4).toString("hex")}`;
|
||||
|
||||
function mockOpencodeSessionMessages(messages: Array<{ role: string; content: string }>) {
|
||||
return {
|
||||
session: {
|
||||
async messages() {
|
||||
return {
|
||||
data: messages.map((m) => ({
|
||||
info: { role: m.role },
|
||||
parts: [{ type: "text", text: m.content }],
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
describeLive("live: OpenCode plugin against Hindsight", () => {
|
||||
// When pointing at the hosted backend, the suite's direct (non-plugin)
|
||||
// retain/recall/deleteBank calls need the same token the plugin reads from
|
||||
// HINDSIGHT_API_TOKEN — otherwise they 401 even when the plugin path is fine.
|
||||
const TOKEN = process.env.HINDSIGHT_API_TOKEN;
|
||||
const client = new HindsightClient(TOKEN ? { baseUrl: URL, apiKey: TOKEN } : { baseUrl: URL });
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await client.deleteBank(BANK);
|
||||
} catch {
|
||||
// bank may not exist if a test bailed early; harmless
|
||||
}
|
||||
});
|
||||
|
||||
it("retain → server-side extraction → recall via tools surfaces the stored fact", async () => {
|
||||
const plugin = await HindsightPlugin(
|
||||
{
|
||||
client: mockOpencodeSessionMessages([]) as any,
|
||||
directory: "/tmp/fake-project",
|
||||
} as any,
|
||||
{ hindsightApiUrl: URL, bankId: BANK, debug: false }
|
||||
);
|
||||
|
||||
const retainOut = await plugin.tool!.hindsight_retain.execute(
|
||||
{ content: "User's favourite programming language is Haskell." } as any,
|
||||
{} as any
|
||||
);
|
||||
expect(String(retainOut)).toMatch(/stored/i);
|
||||
|
||||
// Server-side fact extraction is asynchronous; give it time before recall.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
|
||||
const recallOut = await plugin.tool!.hindsight_recall.execute(
|
||||
{ query: "favourite programming language" } as any,
|
||||
{} as any
|
||||
);
|
||||
expect(String(recallOut).toLowerCase()).toContain("haskell");
|
||||
}, 30_000);
|
||||
|
||||
it("session.idle → auto-retain captures the transcript", async () => {
|
||||
const sessionId = "idle-test-session";
|
||||
const fakeMessages = [
|
||||
{ role: "user", content: "I prefer dark mode and use VS Code." },
|
||||
{ role: "assistant", content: "Noted, dark mode in VS Code." },
|
||||
];
|
||||
|
||||
const plugin = await HindsightPlugin(
|
||||
{
|
||||
client: mockOpencodeSessionMessages(fakeMessages) as any,
|
||||
directory: "/tmp/fake-project",
|
||||
} as any,
|
||||
{
|
||||
hindsightApiUrl: URL,
|
||||
bankId: BANK,
|
||||
retainEveryNTurns: 1,
|
||||
debug: false,
|
||||
}
|
||||
);
|
||||
|
||||
await plugin.event!({
|
||||
event: { type: "session.idle", properties: { sessionID: sessionId } },
|
||||
} as any);
|
||||
|
||||
// Give the auto-retain RPC and the server-side extraction time to land.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
|
||||
const direct = await client.recall(BANK, "IDE preferences");
|
||||
const texts = (direct.results || []).map((r) => r.text.toLowerCase()).join(" | ");
|
||||
expect(texts).toMatch(/vs code|dark mode/);
|
||||
}, 30_000);
|
||||
|
||||
it("session.created + system transform injects recalled context on first prompt", async () => {
|
||||
const sessionId = "first-prompt-session";
|
||||
|
||||
// Seed with content that matches the hardcoded system-transform query
|
||||
// ("project context and recent work").
|
||||
await client.retain(
|
||||
BANK,
|
||||
"Project context: TypeScript monorepo; recent work was on the hindsight-opencode plugin's Cloud-default config."
|
||||
);
|
||||
// Give the server-side extraction time to index the new content.
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
|
||||
const plugin = await HindsightPlugin(
|
||||
{
|
||||
client: mockOpencodeSessionMessages([]) as any,
|
||||
directory: "/tmp/fake-project",
|
||||
} as any,
|
||||
{ hindsightApiUrl: URL, bankId: BANK, debug: false }
|
||||
);
|
||||
|
||||
// First, session.created → marks the session as awaiting first-prompt recall
|
||||
await plugin.event!({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionId, title: "t" } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Then system.transform should inject (bank has matching project context).
|
||||
const sysOut = { system: [] as string[] };
|
||||
await plugin["experimental.chat.system.transform"]!(
|
||||
{ sessionID: sessionId, model: {} } as any,
|
||||
sysOut
|
||||
);
|
||||
|
||||
expect(sysOut.system.length).toBeGreaterThan(0);
|
||||
expect(sysOut.system.join("\n")).toMatch(/hindsight_memories/);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -37,23 +37,18 @@ const state: PluginState = {
|
||||
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 {};
|
||||
}
|
||||
|
||||
// hindsightApiUrl always resolves to a value (DEFAULT_HINDSIGHT_API_URL by default),
|
||||
// so plugin instantiation never fails just because the URL is unset.
|
||||
// Requests fail at call time if no API key is configured for a Cloud URL —
|
||||
// that surfaces a clear, actionable error from the server rather than silently
|
||||
// disabling the plugin.
|
||||
const client = new HindsightClient({
|
||||
baseUrl: apiUrl,
|
||||
baseUrl: config.hindsightApiUrl!,
|
||||
apiKey: config.hindsightApiToken || undefined,
|
||||
});
|
||||
|
||||
const bankId = deriveBankId(config, input.directory);
|
||||
debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`);
|
||||
debugLog(config, `Initialized with bank: ${bankId}, API: ${config.hindsightApiUrl}`);
|
||||
|
||||
const tools = createTools(client, bankId, config, state.missionsSet);
|
||||
const hooks = createHooks(
|
||||
@@ -80,5 +75,5 @@ export default HindsightPlugin;
|
||||
// Re-export types for consumers
|
||||
export type { HindsightConfig } from "./config.js";
|
||||
export type { PluginState } from "./hooks.js";
|
||||
export { loadConfig } from "./config.js";
|
||||
export { loadConfig, DEFAULT_HINDSIGHT_API_URL } from "./config.js";
|
||||
export { deriveBankId } from "./bank.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@vectorize-io/hindsight-client", () => {
|
||||
return { HindsightClient: MockHindsightClient };
|
||||
});
|
||||
|
||||
import { HindsightPlugin } from "./index.js";
|
||||
import { HindsightPlugin, DEFAULT_HINDSIGHT_API_URL } from "./index.js";
|
||||
import { HindsightClient } from "@vectorize-io/hindsight-client";
|
||||
|
||||
const mockPluginInput = {
|
||||
@@ -41,10 +41,19 @@ describe("HindsightPlugin", () => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("returns empty hooks when no API URL configured", async () => {
|
||||
it("defaults to the hosted backend URL when no API URL is configured", async () => {
|
||||
const result = await HindsightPlugin(mockPluginInput as any);
|
||||
expect(result).toEqual({});
|
||||
expect(HindsightClient).not.toHaveBeenCalled();
|
||||
|
||||
expect(HindsightClient).toHaveBeenCalledWith({
|
||||
baseUrl: DEFAULT_HINDSIGHT_API_URL,
|
||||
apiKey: undefined,
|
||||
});
|
||||
// Full tool + hook surface still returned — the plugin doesn't disable
|
||||
// itself just because the URL was left at its default.
|
||||
expect(result.tool).toBeDefined();
|
||||
expect(result.event).toBeDefined();
|
||||
expect(result["experimental.session.compacting"]).toBeDefined();
|
||||
expect(result["experimental.chat.system.transform"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns tools and hooks when configured", async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function makeConfig(overrides: Partial<HindsightConfig> = {}): HindsightC
|
||||
retainContext: "opencode",
|
||||
retainTags: [],
|
||||
retainMetadata: {},
|
||||
hindsightApiUrl: null,
|
||||
hindsightApiUrl: "https://api.hindsight.vectorize.io",
|
||||
hindsightApiToken: null,
|
||||
bankId: null,
|
||||
bankIdPrefix: "",
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface HindsightTools {
|
||||
hindsight_retain: ToolDefinition;
|
||||
hindsight_recall: ToolDefinition;
|
||||
hindsight_reflect: ToolDefinition;
|
||||
// Index signature so the object is assignable to OpenCode's Hooks.tool
|
||||
// (Record<string, ToolDefinition>) without losing the specific keys above.
|
||||
[key: string]: ToolDefinition;
|
||||
}
|
||||
|
||||
export function createTools(
|
||||
|
||||
Reference in New Issue
Block a user