Compare commits

...
Author SHA1 Message Date
DK09876 71840dd23a Merge remote-tracking branch 'origin/main' into fix/opencode-cloud-default-and-e2e 2026-06-05 12:50:19 -07:00
DK09876andClaude Opus 4.7 8d7f05e1d3 chore(opencode): sync openapi.json with main
Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 12:41:40 -07:00
DK09876andClaude Opus 4.7 f159fccbc0 chore(opencode): prettier format README + e2e.test.ts
verify-generated-files CI flagged drift in:
- hindsight-integrations/opencode/README.md
- hindsight-integrations/opencode/src/e2e.test.ts

Both are pure prettier formatting (line wrapping in README, single
quoted -> double quoted spacing in e2e.test.ts). Running
`npx prettier --write` brings the diff to zero.

No behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 12:35:15 -07:00
DK09876andClaude Opus 4.7 0e0b2ae589 test(opencode): pass HINDSIGHT_API_TOKEN to live e2e direct client
The live e2e suite's direct (non-plugin) HindsightClient was constructed
with only { baseUrl: URL }, no apiKey. Against `127.0.0.1:8888` that's
fine — local has no auth. Against `api.hindsight.vectorize.io` the test's
own retain/recall/deleteBank calls 401, masking the fact that the plugin
path itself works against Cloud.

The plugin already reads HINDSIGHT_API_TOKEN from env via its config
resolution. Have the test mirror it: when TOKEN is present, construct
with apiKey. When absent (local-only run), keep the previous shape.

Verified:
- HINDSIGHT_LIVE_E2E=1 against LOCAL (no token):     104/104 pass
- HINDSIGHT_LIVE_E2E=1 against CLOUD (with token):   104/104 pass
- npm test deterministic (no env):                    101/101 + 3 skipped

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:43:20 -07:00
DK09876andClaude Opus 4.7 1ee5206363 fix(opencode): reword 'Hindsight Cloud' in test files for OSS-clean (V2 audit)
V2 audit (2026-06-02) flagged two 'Hindsight Cloud' strings in TS test
files under a strict reading of Goal-4 (which says shipped source — .py
and .ts — should not name the cloud product):

- src/e2e.test.ts:14 (file-header comment): 'For Hindsight Cloud:
  HINDSIGHT_API_TOKEN' → 'When pointing at the hosted backend:
  HINDSIGHT_API_TOKEN'
- src/plugin.test.ts:44 (test description): 'defaults to Hindsight Cloud
  when no API URL' → 'defaults to the hosted backend URL when no API URL'

Test behaviour unchanged. The README and PR descriptions can still
name the product.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:43:20 -07:00
DK09876andClaude Opus 4.7 7f5a8862d9 fix(opencode): default to Hindsight Cloud + gated live E2E
Aligns OpenCode with the cloud-default convention adopted across the
Python integrations (LangGraph, Haystack, OpenAI Agents, LlamaIndex,
AutoGen).

Changes:

- config.ts: introduce DEFAULT_HINDSIGHT_API_URL =
  "https://api.hindsight.vectorize.io". Set DEFAULTS.hindsightApiUrl to
  it so the plugin works out-of-the-box against Hindsight Cloud (API key
  via HINDSIGHT_API_TOKEN). Self-hosters override hindsightApiUrl. Also
  re-export the constant from index.ts.

- index.ts: drop the "No API URL configured" branch that returned empty
  hooks. The URL always resolves now (default = Cloud), so the plugin
  always returns its full tool + hook surface. Requests fail at call
  time with a clear server error if no key is configured against Cloud,
  matching the framework's goal-5 contract ("API key not required at
  construction; fails at call time if missing").

- tools.ts: add an index signature to HindsightTools so the object is
  assignable to OpenCode's Hooks.tool (Record<string, ToolDefinition>)
  without losing the three concrete keys. Fixes a pre-existing dts
  build error that was previously masked by the now-removed empty-hooks
  return branch.

- README.md: restructure Quick Start so Cloud is the primary path
  ("enable plugin + set HINDSIGHT_API_TOKEN"); move self-hosted under a
  secondary heading; update the env-var table to show the new default.

- e2e.test.ts (new): gated live test (skipped unless
  HINDSIGHT_LIVE_E2E=1) covering the three contract surfaces — agent
  tool path (retain → server-side extraction → recall), session.idle
  auto-retain, session.created + system.transform inject. TS equivalent
  of the `requires_real_llm` pytest marker used by the Python
  integrations. Exposed as `npm run test:e2e`.

- plugin.test.ts: replace the "returns empty hooks when no URL" test
  with "defaults to Hindsight Cloud" — asserts the client is constructed
  with DEFAULT_HINDSIGHT_API_URL and the full hook surface is returned.

- config.test.ts + test-helpers.ts: update default-value expectations to
  the new cloud-default constant.

- package.json: version 0.2.0 → 0.2.1; add `test:e2e` script.

Verification:
- Deterministic vitest: 6 files / 101 tests pass, 1 file / 3 tests
  skipped (the gated E2E).
- Live vitest (HINDSIGHT_LIVE_E2E=1, against a local Hindsight server):
  7 files / 104 tests pass.
- `npx tsc --noEmit`: clean.
- `npm run build` (tsup): ESM + DTS both succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:43:20 -07:00
9 changed files with 213 additions and 46 deletions
+27 -24
View File
@@ -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
+2 -1
View File
@@ -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);
});
+8 -13
View File
@@ -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(