diff --git a/hindsight-integrations/opencode/README.md b/hindsight-integrations/opencode/README.md index 24ff19ff8..cce624af3 100644 --- a/hindsight-integrations/opencode/README.md +++ b/hindsight-integrations/opencode/README.md @@ -100,13 +100,127 @@ Create `~/.hindsight/opencode.json` for persistent configuration: } ``` +### Per-Agent API Keys + +OpenCode drives sessions with named agents (e.g. `build`, `code-reviewer`, or +your own custom agents). You can give each agent its own Hindsight API key — +the plugin resolves the key at request time from the **name of the agent** +running the current turn: + +```json +{ + "plugin": [ + [ + "@vectorize-io/opencode-hindsight", + { + "hindsightApiToken": "fallback-key", + "hindsightApiTokens": { + "build": "build-agent-key", + "code-reviewer": "reviewer-key", + "angular-dev-expert": "angular-key" + } + } + ] + ] +} +``` + +Resolution order (first non-empty value wins): + +1. `hindsightApiTokens[]` — the agent driving the turn. + Tools read the agent from OpenCode's tool context; hooks read it from the + most recent user message in the session. +2. `hindsightApiTokens[agentName]` — the entry for the configured default + `agentName`. +3. `hindsightApiToken` — the single static fallback key (legacy behavior). + +Agents with no entry in the map fall back to `hindsightApiToken`, so existing +single-key setups keep working unchanged. Set `"dynamicApiKey": false` to +disable per-agent resolution and force use of `hindsightApiToken` everywhere. + +The per-agent map can also be supplied via the `HINDSIGHT_API_TOKENS` +environment variable as a JSON object (see below). + +### Per-Agent Bank IDs + +By default every agent in a project shares one memory bank (derived from +`bankId`, or the dynamic `agent::project` composition). You can give each +agent its **own memory bank** — the plugin resolves the bank at request time +from the **name of the agent** driving the turn. There are two ways to map +agents to bank IDs. + +#### Agent file frontmatter (highest precedence) + +Add a `bankid` field to the YAML frontmatter of any agent definition file +(`.opencode/agent/.md`, `~/.config/opencode/agents/.md`, etc.): + +```markdown +--- +description: Reviews recently written code +mode: all +bankid: opencode-code-reviewer +--- + +You are an elite code reviewer… +``` + +When present, this value **takes precedence over every other source** +(including `hindsightBankIds` and the static `bankId`). The `bankIdPrefix` +is applied. File results are cached per agent name, keyed on file mtime, so +repeated lookups during a session are cheap. Project-scoped agent files +(`.opencode/agent(s)/`) take precedence over global ones +(`~/.config/opencode/agent(s)/`), mirroring how OpenCode merges configs. + +#### Config-file map (`hindsightBankIds`) + +Map agent names to bank IDs in `opencode.json` or `~/.hindsight/opencode.json`: + +```json +{ + "plugin": [ + [ + "@vectorize-io/opencode-hindsight", + { + "bankId": "fallback-bank", + "hindsightBankIds": { + "build": "build-bank", + "code-reviewer": "reviewer-bank", + "angular-dev-expert": "angular-bank" + } + } + ] + ] +} +``` + +#### Resolution order (first defined value wins) + +1. **Agent `.md` frontmatter `bankid`** — read from the agent's definition + file (highest precedence). `bankIdPrefix` is applied. +2. `hindsightBankIds[]` — the agent driving the turn + (read from the tool context, or the most recent user message in a session). + `bankIdPrefix` is applied. +3. `hindsightBankIds[agentName]` — the entry for the configured default + `agentName`. `bankIdPrefix` is applied. +4. The normal `deriveBankId` result — static `bankId`, or the dynamic + `dynamicBankGranularity` composition (`agent::project`, `gitProject`, …). + `bankIdPrefix` is already applied by the derivation. + +Agents with no `bankid` in their `.md` file and no entry in the map fall back +to the shared bank, so existing single-bank setups keep working unchanged. The +per-agent map can also be supplied via the `HINDSIGHT_BANK_IDS` environment +variable as a JSON object. + ### Environment Variables | 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_API_TOKEN` | API key for authentication (fallback for unmapped agents) | (none — required for Hindsight Cloud) | +| `HINDSIGHT_API_TOKENS` | JSON object mapping agent name → API key | `{}` | +| `HINDSIGHT_DYNAMIC_API_KEY` | Enable per-agent key resolution from `hindsightApiTokens` | `true` | +| `HINDSIGHT_BANK_ID` | Static memory bank ID (fallback for unmapped agents) | `opencode` | +| `HINDSIGHT_BANK_IDS` | JSON object mapping agent name → bank ID | `{}` | | `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` | diff --git a/hindsight-integrations/opencode/package-lock.json b/hindsight-integrations/opencode/package-lock.json index 9e813e736..3c616ce8f 100644 --- a/hindsight-integrations/opencode/package-lock.json +++ b/hindsight-integrations/opencode/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vectorize-io/opencode-hindsight", - "version": "0.2.6", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vectorize-io/opencode-hindsight", - "version": "0.2.6", + "version": "0.2.8", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.3.13", diff --git a/hindsight-integrations/opencode/src/bank.test.ts b/hindsight-integrations/opencode/src/bank.test.ts index 90a3bd7a2..27fe538f2 100644 --- a/hindsight-integrations/opencode/src/bank.test.ts +++ b/hindsight-integrations/opencode/src/bank.test.ts @@ -5,7 +5,17 @@ vi.mock("node:child_process", () => ({ })); import { execFileSync } from "node:child_process"; -import { deriveBankId, ensureBankMission } from "./bank.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + deriveBankId, + ensureBankMission, + resolveBankId, + readAgentBankId, + toBankResolver, + createBankResolver, +} from "./bank.js"; import { makeConfig } from "./test-helpers.js"; const mockExec = vi.mocked(execFileSync); @@ -257,3 +267,244 @@ describe("ensureBankMission", () => { }); }); }); + +describe("resolveBankId", () => { + beforeEach(() => { + mockExec.mockImplementation(() => { + throw new Error("fatal: not a git repository"); + }); + }); + + it("returns the per-agent entry for the running agent", () => { + const config = makeConfig({ + bankId: "default-bank", + hindsightBankIds: { build: "build-bank", "review-agent": "review-bank" }, + }); + expect(resolveBankId(config, "/dir", "build")).toBe("build-bank"); + expect(resolveBankId(config, "/dir", "review-agent")).toBe("review-bank"); + }); + + it("applies bankIdPrefix to per-agent entries", () => { + const config = makeConfig({ + bankId: "default-bank", + bankIdPrefix: "dev", + hindsightBankIds: { build: "build-bank" }, + }); + expect(resolveBankId(config, "/dir", "build")).toBe("dev-build-bank"); + }); + + it("falls back to the default agentName entry", () => { + const config = makeConfig({ + bankId: "default-bank", + agentName: "opencode", + hindsightBankIds: { opencode: "default-agent-bank" }, + }); + expect(resolveBankId(config, "/dir", "security-reviewer")).toBe("default-agent-bank"); + expect(resolveBankId(config, "/dir", undefined)).toBe("default-agent-bank"); + }); + + it("falls back to deriveBankId when the map has no matching entry", () => { + const config = makeConfig({ bankId: "static-bank" }); + expect(resolveBankId(config, "/dir", "build")).toBe("static-bank"); + }); + + it("falls back to dynamic-granularity derivation", () => { + const config = makeConfig({ + dynamicBankId: true, + dynamicBankGranularity: ["agent", "project"], + }); + expect(resolveBankId(config, "/home/user/my-project", "build")).toBe( + "opencode::my-project" + ); + }); +}); + +describe("toBankResolver", () => { + it("wraps a bare string so forAgent() always returns it", () => { + const resolver = toBankResolver("fixed-bank"); + expect(resolver.forAgent("build")).toBe("fixed-bank"); + expect(resolver.forAgent(undefined)).toBe("fixed-bank"); + expect(resolver.forAgent(null)).toBe("fixed-bank"); + }); + + it("passes through an existing BankResolver unchanged", () => { + const resolver = { forAgent: () => "dynamic" }; + expect(toBankResolver(resolver)).toBe(resolver); + }); +}); + +describe("createBankResolver", () => { + it("resolves per-agent, falling back to derivation", () => { + mockExec.mockImplementation(() => { + throw new Error("fatal: not a git repository"); + }); + const config = makeConfig({ + bankId: "default-bank", + hindsightBankIds: { build: "build-bank" }, + }); + const resolver = createBankResolver(config, "/dir"); + expect(resolver.forAgent("build")).toBe("build-bank"); + expect(resolver.forAgent("other")).toBe("default-bank"); + expect(resolver.forAgent()).toBe("default-bank"); + }); +}); + +describe("readAgentBankId (agent .md frontmatter)", () => { + let tmpRoot: string; + + beforeEach(() => { + // Default: simulate "not in a git repo" so deriveBankId fallback works. + mockExec.mockImplementation(() => { + throw new Error("fatal: not a git repository"); + }); + tmpRoot = mkdtempSync(join(tmpdir(), "hindsight-bank-test-")); + }); + + afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + function writeAgentFile( + agentName: string, + frontmatter: Record | null, + body = "" + ): string { + const dir = join(tmpRoot, ".opencode", "agent"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, `${agentName}.md`); + let content = body; + if (frontmatter) { + const fmLines = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + content = `---\n${fmLines}\n---\n${body}`; + } + writeFileSync(path, content, "utf-8"); + return path; + } + + it("reads the bankid field from agent frontmatter", () => { + writeAgentFile("code-reviewer", { + bankid: "reviewer-bank-from-md", + description: "Reviews code", + }); + expect(readAgentBankId("code-reviewer", tmpRoot)).toBe( + "reviewer-bank-from-md" + ); + }); + + it("supports quoted bankid values", () => { + writeAgentFile("build", { bankid: '"quoted-bank"' }); + expect(readAgentBankId("build", tmpRoot)).toBe("quoted-bank"); + }); + + it("supports single-quoted bankid values", () => { + writeAgentFile("build", { bankid: "'single-quoted-bank'" }); + expect(readAgentBankId("build", tmpRoot)).toBe("single-quoted-bank"); + }); + + it("ignores nested keys named bankid (top-level only)", () => { + const dir = join(tmpRoot, ".opencode", "agent"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "build.md"), + // The indented `bankid:` is nested under `permission:` and must be ignored. + `---\ndescription: x\npermission:\n bankid: nested-bank\nbankid: top-level-bank\n---\nbody`, + "utf-8" + ); + expect(readAgentBankId("build", tmpRoot)).toBe("top-level-bank"); + }); + + it("returns null when the agent file has no frontmatter", () => { + writeAgentFile("build", null, "Just body, no frontmatter."); + expect(readAgentBankId("build", tmpRoot)).toBeNull(); + }); + + it("returns null when frontmatter has no bankid field", () => { + writeAgentFile("build", { description: "No bankid here" }); + expect(readAgentBankId("build", tmpRoot)).toBeNull(); + }); + + it("returns null when the agent file does not exist", () => { + expect(readAgentBankId("nonexistent", tmpRoot)).toBeNull(); + }); + + it("returns null when agentName is null or undefined", () => { + expect(readAgentBankId(null, tmpRoot)).toBeNull(); + expect(readAgentBankId(undefined, tmpRoot)).toBeNull(); + }); + + it("caches based on file mtime", () => { + const path = writeAgentFile("build", { bankid: "v1-bank" }); + expect(readAgentBankId("build", tmpRoot)).toBe("v1-bank"); + // Overwrite with a new bankid; mtime likely advances → cache should refresh. + // Add a tiny delay on platforms with coarse mtime resolution. + writeFileSync(path, `---\nbankid: v2-bank\n---\nbody`, "utf-8"); + expect(readAgentBankId("build", tmpRoot)).toBe("v2-bank"); + }); + + it("checks .opencode/agents/ (plural) as a fallback location", () => { + const dir = join(tmpRoot, ".opencode", "agents"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "plural-agent.md"), + `---\nbankid: plural-bank\n---\nbody`, + "utf-8" + ); + expect(readAgentBankId("plural-agent", tmpRoot)).toBe("plural-bank"); + }); +}); + +describe("resolveBankId with agent .md frontmatter precedence", () => { + let tmpRoot: string; + + beforeEach(() => { + mockExec.mockImplementation(() => { + throw new Error("fatal: not a git repository"); + }); + tmpRoot = mkdtempSync(join(tmpdir(), "hindsight-bank-test-")); + }); + + afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + function writeAgentFile(agentName: string, bankId: string): void { + const dir = join(tmpRoot, ".opencode", "agent"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${agentName}.md`), + `---\ndescription: test\nbankid: ${bankId}\n---\nbody`, + "utf-8" + ); + } + + it("agent .md frontmatter takes precedence over hindsightBankIds", () => { + writeAgentFile("code-reviewer", "from-md-bank"); + const config = makeConfig({ + bankId: "default-bank", + hindsightBankIds: { "code-reviewer": "from-map-bank" }, + }); + expect(resolveBankId(config, tmpRoot, "code-reviewer")).toBe("from-md-bank"); + }); + + it("applies bankIdPrefix to the frontmatter bankid", () => { + writeAgentFile("build", "md-bank"); + const config = makeConfig({ bankIdPrefix: "dev" }); + expect(resolveBankId(config, tmpRoot, "build")).toBe("dev-md-bank"); + }); + + it("falls back to hindsightBankIds when no frontmatter bankid", () => { + // No .md file written for this agent. + const config = makeConfig({ + bankId: "default-bank", + hindsightBankIds: { build: "from-map-bank" }, + }); + expect(resolveBankId(config, tmpRoot, "build")).toBe("from-map-bank"); + }); + + it("falls back to deriveBankId when neither frontmatter nor map match", () => { + const config = makeConfig({ bankId: "static-bank" }); + expect(resolveBankId(config, tmpRoot, "unknown-agent")).toBe("static-bank"); + }); +}); diff --git a/hindsight-integrations/opencode/src/bank.ts b/hindsight-integrations/opencode/src/bank.ts index af62a57c0..889ba8dce 100644 --- a/hindsight-integrations/opencode/src/bank.ts +++ b/hindsight-integrations/opencode/src/bank.ts @@ -13,8 +13,10 @@ * directory is not a repo. */ -import { basename, dirname } from "node:path"; +import { basename, dirname, join } from "node:path"; import { execFileSync } from "node:child_process"; +import { readFileSync, existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; import type { HindsightConfig } from "./config.js"; import { Logger } from "./logger.js"; import type { HindsightClient } from "@vectorize-io/hindsight-client"; @@ -147,3 +149,174 @@ export async function ensureBankMission( logger.debug(`Could not set bank mission for ${bankId}`, { error: String(e) }); } } + +/** + * Search locations for agent definition files, in priority order. + * Project-scoped files take precedence over global ones (mirroring how + * OpenCode merges configs). + */ +const AGENT_FILE_DIRS = (directory: string): string[] => [ + join(directory, ".opencode", "agent"), + join(directory, ".opencode", "agents"), + join(homedir(), ".config", "opencode", "agent"), + join(homedir(), ".config", "opencode", "agents"), +]; + +/** Cache: agent name → { mtime, bankId } so we re-read only when the file changes. */ +const agentFileCache = new Map(); + +/** + * Extract the `bankid` scalar from YAML frontmatter without a full YAML parser. + * Handles `bankid: value`, `"value"`, and `'value'` forms. Only top-level keys + * (column 0) are matched, so nested keys with the same name are ignored. + */ +function extractBankIdFromFrontmatter(content: string): string | null { + // Find frontmatter block between the first pair of `---` lines. + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + const body = match[1]; + for (const line of body.split("\n")) { + // Top-level key only (no leading whitespace). + const m = line.match(/^bankid:\s*(.*)$/i); + if (m) { + let val = m[1].trim(); + // Strip surrounding quotes. + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + return val || null; + } + } + return null; +} + +/** + * Read the `bankid` field from an agent's `.md` definition file. + * + * Searches the standard agent file locations (project then global) and returns + * the frontmatter `bankid` value if present. Results are cached per agent name + * keyed on file mtime, so repeated calls during a session are cheap. + * + * Returns `null` when the agent file is not found, has no frontmatter, or the + * frontmatter does not declare a `bankid`. + */ +export function readAgentBankId( + agentName: string | null | undefined, + directory: string +): string | null { + if (!agentName) return null; + + const dirs = AGENT_FILE_DIRS(directory); + let filePath: string | null = null; + for (const dir of dirs) { + const candidate = join(dir, `${agentName}.md`); + if (existsSync(candidate)) { + filePath = candidate; + break; + } + } + if (!filePath) return null; + + let mtime: number; + try { + mtime = statSync(filePath).mtimeMs; + } catch { + return null; + } + + const cached = agentFileCache.get(filePath); + if (cached && cached.mtime === mtime) { + return cached.bankId; + } + + let bankId: string | null = null; + try { + const raw = readFileSync(filePath, "utf-8"); + bankId = extractBankIdFromFrontmatter(raw); + } catch { + bankId = null; + } + + agentFileCache.set(filePath, { mtime, bankId }); + return bankId; +} + +/** + * Resolve the bank ID for a given agent name, applying the per-agent + * `hindsightBankIds` map with a fallback to the normal `deriveBankId` result. + * + * Resolution order (first defined value wins): + * 1. Agent `.md` frontmatter `bankid` field — read from the agent's + * definition file. This takes precedence over all other sources. + * The `bankIdPrefix` is applied. + * 2. `hindsightBankIds[agentName]` — explicit entry for the running agent. + * The `bankIdPrefix` is applied. + * 3. `hindsightBankIds[config.agentName]` — entry for the configured + * default agent name. The `bankIdPrefix` is applied. + * 4. `deriveBankId(config, directory)` — the legacy derivation (static + * `bankId` or dynamic-granularity composition). Already applies the prefix. + * + * `agentName` is the name of the OpenCode agent currently driving the session + * (e.g. "build", "code-reviewer"). Pass `null`/`undefined` when the agent + * name is unknown — resolution then skips straight to the fallback. + */ +export function resolveBankId( + config: HindsightConfig, + directory: string, + agentName?: string | null +): string { + const prefix = config.bankIdPrefix; + const applyPrefix = (base: string) => (prefix ? `${prefix}-${base}` : base); + + // 1. Agent .md frontmatter (highest precedence) + const agentFileBankId = readAgentBankId(agentName, directory); + if (agentFileBankId) { + return applyPrefix(agentFileBankId); + } + + // 2. Per-agent map + if (agentName && config.hindsightBankIds?.[agentName]) { + return applyPrefix(config.hindsightBankIds[agentName]); + } + // 3. Default agentName entry + if (config.hindsightBankIds?.[config.agentName]) { + return applyPrefix(config.hindsightBankIds[config.agentName]); + } + // 4. Legacy derivation + return deriveBankId(config, directory); +} + +/** Anything that can resolve a bank ID for a given agent name. */ +export interface BankResolver { + forAgent(agentName?: string | null): string; +} + +/** + * Normalize a `string | BankResolver` into a `BankResolver`. A bare string + * (e.g. a fixed bank ID in unit tests) is wrapped so `forAgent()` always + * returns it, regardless of agent — keeping existing call sites unchanged. + */ +export function toBankResolver(bankIdOrResolver: string | BankResolver): BankResolver { + const maybe = bankIdOrResolver as Partial; + if (typeof maybe.forAgent === "function") { + return bankIdOrResolver as BankResolver; + } + const bankId = bankIdOrResolver as string; + return { forAgent: () => bankId }; +} + +/** + * Build a `BankResolver` that resolves the bank ID per-agent from + * `hindsightBankIds`, falling back to the legacy `deriveBankId` derivation. + */ +export function createBankResolver( + config: HindsightConfig, + directory: string +): BankResolver { + return { + forAgent: (agentName?: string | null) => resolveBankId(config, directory, agentName), + }; +} diff --git a/hindsight-integrations/opencode/src/config.test.ts b/hindsight-integrations/opencode/src/config.test.ts index 2dff0f681..cf18ea121 100644 --- a/hindsight-integrations/opencode/src/config.test.ts +++ b/hindsight-integrations/opencode/src/config.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { loadConfig, DEFAULT_HINDSIGHT_API_URL, type HindsightConfig } from "./config.js"; +import { + loadConfig, + resolveApiKey, + DEFAULT_HINDSIGHT_API_URL, + type HindsightConfig, +} from "./config.js"; +import { resolveBankId } from "./bank.js"; describe("loadConfig", () => { const originalEnv = { ...process.env }; @@ -151,4 +157,162 @@ describe("loadConfig", () => { expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); + + it("defaults hindsightApiTokens to {} and dynamicApiKey to true", () => { + const config = loadConfig(); + expect(config.hindsightApiTokens).toEqual({}); + expect(config.dynamicApiKey).toBe(true); + }); + + it("parses HINDSIGHT_API_TOKENS as a JSON object", () => { + process.env.HINDSIGHT_API_TOKENS = '{"build":"k1","code-reviewer":"k2"}'; + const config = loadConfig(); + expect(config.hindsightApiTokens).toEqual({ build: "k1", "code-reviewer": "k2" }); + }); + + it("ignores malformed HINDSIGHT_API_TOKENS with a warning", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.HINDSIGHT_API_TOKENS = "not-json"; + const config = loadConfig(); + expect(config.hindsightApiTokens).toEqual({}); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse")); + spy.mockRestore(); + }); + + it("ignores non-object HINDSIGHT_API_TOKENS with a warning", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.HINDSIGHT_API_TOKENS = '["a","b"]'; + const config = loadConfig(); + expect(config.hindsightApiTokens).toEqual({}); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("JSON object")); + spy.mockRestore(); + }); + + it("HINDSIGHT_DYNAMIC_API_KEY=false disables dynamic keys", () => { + process.env.HINDSIGHT_DYNAMIC_API_KEY = "false"; + expect(loadConfig().dynamicApiKey).toBe(false); + }); + + it("defaults hindsightBankIds to {}", () => { + const config = loadConfig(); + expect(config.hindsightBankIds).toEqual({}); + }); + + it("parses HINDSIGHT_BANK_IDS as a JSON object", () => { + process.env.HINDSIGHT_BANK_IDS = '{"build":"build-bank","code-reviewer":"review-bank"}'; + const config = loadConfig(); + expect(config.hindsightBankIds).toEqual({ build: "build-bank", "code-reviewer": "review-bank" }); + }); + + it("ignores malformed HINDSIGHT_BANK_IDS with a warning", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.HINDSIGHT_BANK_IDS = "not-json"; + const config = loadConfig(); + expect(config.hindsightBankIds).toEqual({}); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse HINDSIGHT_BANK_IDS")); + spy.mockRestore(); + }); + + it("ignores non-object HINDSIGHT_BANK_IDS with a warning", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.HINDSIGHT_BANK_IDS = '["a","b"]'; + const config = loadConfig(); + expect(config.hindsightBankIds).toEqual({}); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("JSON object")); + spy.mockRestore(); + }); +}); + +describe("resolveBankId", () => { + it("returns the per-agent bank ID when the agent has an entry", () => { + const config = loadConfig({ + bankId: "default-bank", + hindsightBankIds: { build: "build-bank", "review-agent": "review-bank" }, + }); + expect(resolveBankId(config, "/dir", "build")).toBe("build-bank"); + expect(resolveBankId(config, "/dir", "review-agent")).toBe("review-bank"); + }); + + it("applies bankIdPrefix to per-agent bank IDs", () => { + const config = loadConfig({ + bankId: "default-bank", + bankIdPrefix: "dev", + hindsightBankIds: { build: "build-bank" }, + }); + expect(resolveBankId(config, "/dir", "build")).toBe("dev-build-bank"); + }); + + it("falls back to the default agentName entry when agent is unknown", () => { + const config = loadConfig({ + bankId: "default-bank", + agentName: "opencode", + hindsightBankIds: { opencode: "default-agent-bank" }, + }); + expect(resolveBankId(config, "/dir", "security-reviewer")).toBe("default-agent-bank"); + expect(resolveBankId(config, "/dir", undefined)).toBe("default-agent-bank"); + }); + + it("falls back to deriveBankId when no per-agent entry matches", () => { + const config = loadConfig({ bankId: "static-bank" }); + expect(resolveBankId(config, "/dir", "build")).toBe("static-bank"); + }); + + it("falls back to dynamic-granularity derivation when nothing in the map matches", () => { + const config = loadConfig({ + dynamicBankId: true, + dynamicBankGranularity: ["agent", "project"], + hindsightBankIds: { "other-agent": "other-bank" }, + }); + expect(resolveBankId(config, "/home/user/my-project", "build")).toBe( + "opencode::my-project" + ); + }); +}); + +describe("resolveApiKey", () => { + it("returns the static token when no per-agent map is configured", () => { + const config = loadConfig({ hindsightApiToken: "static" }); + expect(resolveApiKey(config, "build")).toBe("static"); + }); + + it("returns the per-agent token when the agent has an entry", () => { + const config = loadConfig({ + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key", "code-reviewer": "review-key" }, + }); + expect(resolveApiKey(config, "build")).toBe("build-key"); + expect(resolveApiKey(config, "code-reviewer")).toBe("review-key"); + }); + + it("falls back to the static token for agents with no entry", () => { + const config = loadConfig({ + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key" }, + }); + expect(resolveApiKey(config, "security-reviewer")).toBe("static"); + }); + + it("falls back to the configured agentName entry when agent is unknown", () => { + const config = loadConfig({ + agentName: "opencode", + hindsightApiToken: "static", + hindsightApiTokens: { opencode: "default-agent-key" }, + }); + expect(resolveApiKey(config)).toBe("default-agent-key"); + expect(resolveApiKey(config, undefined)).toBe("default-agent-key"); + }); + + it("ignores the per-agent map when dynamicApiKey is false", () => { + const config = loadConfig({ + dynamicApiKey: false, + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key" }, + }); + expect(resolveApiKey(config, "build")).toBe("static"); + }); + + it("returns null when no token is configured anywhere", () => { + const config = loadConfig(); + expect(resolveApiKey(config, "build")).toBeNull(); + }); }); diff --git a/hindsight-integrations/opencode/src/config.ts b/hindsight-integrations/opencode/src/config.ts index 4a2f90066..98ab3a9a8 100644 --- a/hindsight-integrations/opencode/src/config.ts +++ b/hindsight-integrations/opencode/src/config.ts @@ -39,6 +39,16 @@ export interface HindsightConfig { // Connection hindsightApiUrl: string | null; hindsightApiToken: string | null; + /** + * Per-agent API tokens. When `dynamicApiKey` is enabled (default) and the + * running agent's name is a key in this map, the corresponding token is used + * for that agent's requests instead of `hindsightApiToken`. Agent names with + * no entry fall back to `hindsightApiTokens[agentName]` (config default) then + * to `hindsightApiToken`. + */ + hindsightApiTokens: Record; + /** When true (default), resolve the API token per-agent from `hindsightApiTokens`. */ + dynamicApiKey: boolean; // Bank bankId: string | null; @@ -48,6 +58,15 @@ export interface HindsightConfig { bankMission: string; retainMission: string | null; agentName: string; + /** + * Per-agent bank IDs. When the running agent's name is a key in this map, the + * corresponding bank ID is used for that agent's requests instead of the + * derived bank ID. The `bankIdPrefix` is applied to the mapped value. + * Agent names with no entry fall back to `hindsightBankIds[agentName]` (the + * configured default agent) then to the normal `deriveBankId` result (static + * `bankId` or dynamic-granularity composition). + */ + hindsightBankIds: Record; // Misc debug: boolean; @@ -80,6 +99,8 @@ const DEFAULTS: HindsightConfig = { // Connection hindsightApiUrl: DEFAULT_HINDSIGHT_API_URL, hindsightApiToken: null, + hindsightApiTokens: {}, + dynamicApiKey: true, // Bank bankId: null, @@ -89,6 +110,7 @@ const DEFAULTS: HindsightConfig = { bankMission: "", retainMission: null, agentName: "opencode", + hindsightBankIds: {}, // Misc debug: false, @@ -98,6 +120,7 @@ const DEFAULTS: HindsightConfig = { const ENV_OVERRIDES: Record = { HINDSIGHT_API_URL: ["hindsightApiUrl", "string"], HINDSIGHT_API_TOKEN: ["hindsightApiToken", "string"], + HINDSIGHT_DYNAMIC_API_KEY: ["dynamicApiKey", "bool"], HINDSIGHT_BANK_ID: ["bankId", "string"], HINDSIGHT_AGENT_NAME: ["agentName", "string"], HINDSIGHT_AUTO_RECALL: ["autoRecall", "bool"], @@ -195,6 +218,56 @@ export function loadConfig(pluginOptions?: Record): HindsightCo .filter(Boolean); } + // Per-agent API token map (JSON object: { "agentName": "token", ... }). + const apiTokensEnv = process.env["HINDSIGHT_API_TOKENS"]; + if (apiTokensEnv !== undefined) { + try { + const parsed = JSON.parse(apiTokensEnv); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const tokens: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string") tokens[k] = v; + } + config["hindsightApiTokens"] = tokens; + } else { + console.error( + `[Hindsight] HINDSIGHT_API_TOKENS must be a JSON object — ignoring.` + ); + } + } catch (e) { + console.error( + `[Hindsight] Failed to parse HINDSIGHT_API_TOKENS as JSON — ignoring. ${ + String(e).split("\n")[0] + }` + ); + } + } + + // Per-agent bank ID map (JSON object: { "agentName": "bank-id", ... }). + const bankIdsEnv = process.env["HINDSIGHT_BANK_IDS"]; + if (bankIdsEnv !== undefined) { + try { + const parsed = JSON.parse(bankIdsEnv); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const ids: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string") ids[k] = v; + } + config["hindsightBankIds"] = ids; + } else { + console.error( + `[Hindsight] HINDSIGHT_BANK_IDS must be a JSON object — ignoring.` + ); + } + } catch (e) { + console.error( + `[Hindsight] Failed to parse HINDSIGHT_BANK_IDS as JSON — ignoring. ${ + String(e).split("\n")[0] + }` + ); + } + } + const result = config as unknown as HindsightConfig; // Validate enum-like fields to catch typos early @@ -227,3 +300,31 @@ export function loadConfig(pluginOptions?: Record): HindsightCo return result; } + +/** + * Resolve the Hindsight API token for a given agent name. + * + * Resolution order (first non-empty wins): + * 1. `hindsightApiTokens[agentName]` — when `dynamicApiKey` is enabled and an + * explicit entry exists for the running agent. + * 2. `hindsightApiTokens[config.agentName]` — the entry for the configured + * default agent name. + * 3. `hindsightApiToken` — the single static token (legacy behavior). + * + * `agentName` is the name of the OpenCode agent currently driving the session + * (e.g. "build", "code-reviewer"), as reported by OpenCode's tool/session + * context. Pass `null`/`undefined` when the agent name is unknown. + */ +export function resolveApiKey( + config: HindsightConfig, + agentName?: string | null +): string | null { + if (config.dynamicApiKey && agentName) { + const perAgent = config.hindsightApiTokens?.[agentName]; + if (perAgent) return perAgent; + } + if (config.dynamicApiKey && config.hindsightApiTokens?.[config.agentName]) { + return config.hindsightApiTokens[config.agentName]!; + } + return config.hindsightApiToken; +} diff --git a/hindsight-integrations/opencode/src/hooks.test.ts b/hindsight-integrations/opencode/src/hooks.test.ts index 4fafd1005..7dbdb5d93 100644 --- a/hindsight-integrations/opencode/src/hooks.test.ts +++ b/hindsight-integrations/opencode/src/hooks.test.ts @@ -525,3 +525,181 @@ describe("system transform hook", () => { expect(client.recall.mock.calls[0][1]).toBe("project context and recent work"); }); }); + +describe("dynamic per-agent keys", () => { + it("routes recall to the client resolved for the session's agent", async () => { + const buildClient = makeClient(); + buildClient.recall.mockResolvedValue({ + results: [{ text: "build memory", type: "world" }], + }); + const reviewClient = makeClient(); + reviewClient.recall.mockResolvedValue({ + results: [{ text: "review memory", type: "world" }], + }); + const resolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? reviewClient : buildClient + ), + }; + + const messages = [ + { info: { role: "user", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hi" }] }, + ]; + + const hooks = createHooks( + resolver as any, + "bank", + makeConfig(), + makeState(), + makeOpencodeClient(messages) + ); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output); + + expect(resolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(reviewClient.recall).toHaveBeenCalled(); + expect(buildClient.recall).not.toHaveBeenCalled(); + expect(output.system[0]).toContain("review memory"); + }); + + it("routes idle retain to the client resolved for the session's agent", async () => { + const buildClient = makeClient(); + const reviewClient = makeClient(); + const resolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? reviewClient : buildClient + ), + }; + + const messages = [ + { info: { role: "user", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hi" }] }, + { info: { role: "assistant", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hey" }] }, + ]; + + const hooks = createHooks( + resolver as any, + "bank", + makeConfig({ retainEveryNTurns: 1 }), + makeState(), + makeOpencodeClient(messages) + ); + + await hooks.event({ + event: { type: "session.idle", properties: { sessionID: "sess-1" } }, + }); + + expect(resolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(reviewClient.retain).toHaveBeenCalledTimes(1); + expect(buildClient.retain).not.toHaveBeenCalled(); + }); + + it("falls back to the default client when messages have no agent field", async () => { + const client = makeClient(); + const resolver = { + forAgent: vi.fn(() => client), + }; + const messages = [ + { info: { role: "user" }, parts: [{ type: "text", text: "Hi" }] }, + ]; + + const hooks = createHooks( + resolver as any, + "bank", + makeConfig(), + makeState(), + makeOpencodeClient(messages) + ); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output); + + // No agent on the messages → resolver.forAgent called with undefined. + expect(resolver.forAgent).toHaveBeenCalledWith(undefined); + expect(client.recall).toHaveBeenCalled(); + }); +}); + +describe("dynamic per-agent bank IDs", () => { + it("routes recall to the bank resolved for the session's agent", async () => { + const client = makeClient(); + client.recall.mockResolvedValue({ + results: [{ text: "memory", type: "world" }], + }); + const bankResolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? "review-bank" : "build-bank" + ), + }; + + const messages = [ + { info: { role: "user", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hi" }] }, + ]; + + const hooks = createHooks( + client, + bankResolver as any, + makeConfig(), + makeState(), + makeOpencodeClient(messages) + ); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output); + + expect(bankResolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(client.recall.mock.calls[0][0]).toBe("review-bank"); + }); + + it("routes idle retain to the bank resolved for the session's agent", async () => { + const client = makeClient(); + const bankResolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? "review-bank" : "build-bank" + ), + }; + + const messages = [ + { info: { role: "user", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hi" }] }, + { info: { role: "assistant", agent: "code-reviewer" }, parts: [{ type: "text", text: "Hey" }] }, + ]; + + const hooks = createHooks( + client, + bankResolver as any, + makeConfig({ retainEveryNTurns: 1 }), + makeState(), + makeOpencodeClient(messages) + ); + + await hooks.event({ + event: { type: "session.idle", properties: { sessionID: "sess-1" } }, + }); + + expect(bankResolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(client.retain.mock.calls[0][0]).toBe("review-bank"); + }); + + it("uses the fallback bank when the agent has no per-agent entry", async () => { + const client = makeClient(); + const bankResolver = { + forAgent: vi.fn(() => "fallback-bank"), + }; + const messages = [ + { info: { role: "user", agent: "security-reviewer" }, parts: [{ type: "text", text: "Hi" }] }, + ]; + + const hooks = createHooks( + client, + bankResolver as any, + makeConfig(), + makeState(), + makeOpencodeClient(messages) + ); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]({ sessionID: "sess-1", model: {} }, output); + + expect(client.recall.mock.calls[0][0]).toBe("fallback-bank"); + }); +}); diff --git a/hindsight-integrations/opencode/src/hooks.ts b/hindsight-integrations/opencode/src/hooks.ts index 77c664e7e..a25efaf7e 100644 --- a/hindsight-integrations/opencode/src/hooks.ts +++ b/hindsight-integrations/opencode/src/hooks.ts @@ -12,6 +12,8 @@ import type { HindsightClient } from "@vectorize-io/hindsight-client"; import type { HindsightConfig } from "./config.js"; import { Logger } from "./logger.js"; +import { toResolver, type ClientResolver } from "./registry.js"; +import { toBankResolver, type BankResolver } from "./bank.js"; import { formatMemories, formatCurrentTime, @@ -57,13 +59,15 @@ interface SystemTransformOutput { system: string[]; } +interface RawMessage { + info: { role: string; agent?: string }; + parts: Array<{ type: string; text?: string }>; +} + type OpencodeClient = { session: { messages: (params: { path: { id: string } }) => Promise<{ - data?: Array<{ - info: { role: string }; - parts: Array<{ type: string; text?: string }>; - }>; + data?: Array; error?: unknown; request?: unknown; response?: unknown; @@ -84,13 +88,15 @@ export interface HindsightHooks { } export function createHooks( - hindsightClient: HindsightClient, - bankId: string, + hindsightClientOrResolver: HindsightClient | ClientResolver, + bankIdOrResolver: string | BankResolver, config: HindsightConfig, state: PluginState, opencodeClient: OpencodeClient, logger: Logger = new Logger({ silent: true }) ): HindsightHooks { + const resolver = toResolver(hindsightClientOrResolver); + const bankResolver = toBankResolver(bankIdOrResolver); interface RecallOutcome { /** formatted context string, or null if no results */ context: string | null; @@ -98,10 +104,27 @@ export function createHooks( ok: boolean; } + /** + * Extract the agent name driving `sessionId` from OpenCode's session + * messages (most recent user message's `info.agent`). Returns `undefined` + * when no user message is present yet — the registry then falls back to the + * default (static) token. + */ + function agentFromRaw(raw: RawMessage[]): string | undefined { + for (let i = raw.length - 1; i >= 0; i--) { + if (raw[i].info.role === "user") return raw[i].info.agent; + } + return undefined; + } + /** Recall memories and format as context string */ - async function recallForContext(query: string): Promise { + async function recallForContext( + query: string, + client: HindsightClient, + bankId: string + ): Promise { try { - const response = await hindsightClient.recall(bankId, query, { + const response = await client.recall(bankId, query, { budget: config.recallBudget as "low" | "mid" | "high", maxTokens: config.recallMaxTokens, types: config.recallTypes, @@ -126,8 +149,10 @@ export function createHooks( } } - /** Extract plain-text messages from an OpenCode session */ - async function getSessionMessages(sessionId: string): Promise { + /** Extract plain-text messages and the driving agent from an OpenCode session */ + async function getSessionMessages( + sessionId: string + ): Promise<{ messages: Message[]; agent?: string }> { try { logger.debug(`getSessionMessages: fetching messages for session ${sessionId}`); const response = await opencodeClient.session.messages({ @@ -148,11 +173,12 @@ export function createHooks( messages.push({ role, content: textParts.join("\n") }); } } - logger.debug(`getSessionMessages: raw=${rawMessages.length}, parsed=${messages.length}`); - return messages; + const agent = agentFromRaw(rawMessages); + logger.debug(`getSessionMessages: raw=${rawMessages.length}, parsed=${messages.length}, agent=${agent ?? "(none)"}`); + return { messages, agent }; } catch (e) { logger.error("Failed to get session messages", e); - return []; + return { messages: [] }; } } @@ -160,7 +186,13 @@ export function createHooks( * 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 { + async function retainSession( + sessionId: string, + messages: Message[], + agent?: string + ): Promise { + const client = resolver.forAgent(agent); + const bankId = bankResolver.forAgent(agent); const retainFullWindow = config.retainMode === "full-session"; let targetMessages: Message[]; let documentId: string; @@ -180,8 +212,8 @@ export function createHooks( const { transcript } = prepareRetentionTranscript(targetMessages, true); if (!transcript) return; - await ensureBankMission(hindsightClient, bankId, config, state.missionsSet, logger); - await hindsightClient.retain(bankId, transcript, { + await ensureBankMission(client, bankId, config, state.missionsSet, logger); + await client.retain(bankId, transcript, { documentId, context: config.retainContext, tags: config.retainTags.length ? config.retainTags : undefined, @@ -197,7 +229,7 @@ export function createHooks( logger.debug(`handleSessionIdle called for session ${sessionId}`); if (!config.autoRetain) return; - const messages = await getSessionMessages(sessionId); + const { messages, agent } = await getSessionMessages(sessionId); if (!messages.length) return; // Count user turns @@ -211,11 +243,11 @@ export function createHooks( if (userTurns - lastRetained < config.retainEveryNTurns) return; try { - await retainSession(sessionId, messages); + await retainSession(sessionId, messages, agent); state.lastRetainedTurn.set(sessionId, userTurns); logger.info(`Auto-retained ${messages.length} messages`, { session: sessionId, - bank: bankId, + bank: bankResolver.forAgent(agent), }); } catch (e) { logger.error("Auto-retain failed", e); @@ -246,10 +278,10 @@ export function createHooks( const compacting = async (input: CompactingInput, output: CompactingOutput): Promise => { try { // First, retain what we have before compaction (using shared retention logic) - const messages = await getSessionMessages(input.sessionID); + const { messages, agent } = await getSessionMessages(input.sessionID); if (messages.length && config.autoRetain) { try { - await retainSession(input.sessionID, messages); + await retainSession(input.sessionID, messages, agent); // Reset turn tracking — after compaction the message list shrinks, // so the old lastRetainedTurn value would block future idle retains. state.lastRetainedTurn.delete(input.sessionID); @@ -273,7 +305,11 @@ export function createHooks( lastUserMsg.content, config.recallMaxQueryChars ); - const { context } = await recallForContext(truncated); + const { context } = await recallForContext( + truncated, + resolver.forAgent(agent), + bankResolver.forAgent(agent) + ); if (context) { output.context.push(context); } @@ -299,8 +335,6 @@ export function createHooks( // whether session.created fired first (see #1758). if (state.recalledSessions.has(sessionId)) return; - await ensureBankMission(hindsightClient, bankId, config, state.missionsSet, logger); - // Build the recall query from the user's own messages so session-start // recall adapts to what they actually asked, instead of a fixed string. // We fetch messages directly (the hook input only carries sessionID/model) @@ -308,7 +342,13 @@ export function createHooks( // rather than relying on event ordering also sidesteps the // session.created-vs-system.transform race noted above (#1758). When there // is no user text yet, fall back to a generic project-context query. - const messages = await getSessionMessages(sessionId); + // The agent driving the session selects the per-agent API key (if any). + const { messages, agent } = await getSessionMessages(sessionId); + const client = resolver.forAgent(agent); + const bankId = bankResolver.forAgent(agent); + + await ensureBankMission(client, bankId, config, state.missionsSet, logger); + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); let query = `project context and recent work`; if (lastUserMsg && lastUserMsg.content.trim()) { @@ -319,7 +359,7 @@ export function createHooks( ); query = truncateRecallQuery(composed, lastUserMsg.content, config.recallMaxQueryChars); } - const { context, ok } = await recallForContext(query); + const { context, ok } = await recallForContext(query, client, bankId); // Mark as recalled only after a successful API round-trip (even with 0 // results), so transient failures retry on the next message. diff --git a/hindsight-integrations/opencode/src/index.ts b/hindsight-integrations/opencode/src/index.ts index 507583762..ef1406a9c 100644 --- a/hindsight-integrations/opencode/src/index.ts +++ b/hindsight-integrations/opencode/src/index.ts @@ -19,11 +19,12 @@ import type { Plugin } from "@opencode-ai/plugin"; import { HindsightClient } from "@vectorize-io/hindsight-client"; -import { loadConfig } from "./config.js"; -import { deriveBankId } from "./bank.js"; +import { loadConfig, resolveApiKey } from "./config.js"; +import { createBankResolver } from "./bank.js"; import { createTools } from "./tools.js"; import { createHooks, type PluginState } from "./hooks.js"; import { Logger, type OpencodeLogClient } from "./logger.js"; +import { ClientRegistry } from "./registry.js"; // Module-level state persists across sessions (plugin is instantiated per session, // but the module is loaded once per OpenCode server process). @@ -44,31 +45,48 @@ const HindsightPlugin: Plugin = async (input, options) => { debug: config.debug, }); - // hindsightApiUrl always resolves to a value (DEFAULT_HINDSIGHT_API_URL by default), +// 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({ + // + // The default client is built eagerly from the static fallback token + // (resolveApiKey with no agent) so legacy single-token setups and the plugin + // tests that assert eager construction keep working. Per-agent tokens are + // resolved lazily via the registry (see ClientRegistry). + const defaultToken = resolveApiKey(config); + const defaultClient = new HindsightClient({ baseUrl: config.hindsightApiUrl!, - apiKey: config.hindsightApiToken || undefined, + apiKey: defaultToken || undefined, + }); + const registry = new ClientRegistry({ + baseUrl: config.hindsightApiUrl!, + config, + defaultClient, + logger, }); - const bankId = deriveBankId(config, input.directory); + const bankResolver = createBankResolver(config, input.directory); + const defaultBankId = bankResolver.forAgent(); // Always log the resolved endpoint + bank so users can see which instance the // plugin is talking to (a common source of "memories aren't saving" confusion). logger.info("Hindsight plugin initialized", { api: config.hindsightApiUrl, - bank: bankId, - authenticated: Boolean(config.hindsightApiToken), + bank: defaultBankId, + authenticated: Boolean(defaultToken), + dynamicApiKey: config.dynamicApiKey, + perAgentTokens: Object.keys(config.hindsightApiTokens || {}).length, + dynamicBankId: config.dynamicBankId, + perAgentBankIds: Object.keys(config.hindsightBankIds || {}).length, autoRecall: config.autoRecall, autoRetain: config.autoRetain, }); - const tools = createTools(client, bankId, config, state.missionsSet, logger); + const tools = createTools(registry, bankResolver, config, state.missionsSet, logger); const hooks = createHooks( - client, - bankId, + registry, + bankResolver, config, state, input.client as unknown as Parameters[4], @@ -94,3 +112,5 @@ export default HindsightPlugin; // Re-export types for consumers export type { HindsightConfig } from "./config.js"; export type { PluginState } from "./hooks.js"; +export type { ClientRegistry, ClientResolver } from "./registry.js"; +export type { BankResolver } from "./bank.js"; diff --git a/hindsight-integrations/opencode/src/registry.test.ts b/hindsight-integrations/opencode/src/registry.test.ts new file mode 100644 index 000000000..63b9c4cbe --- /dev/null +++ b/hindsight-integrations/opencode/src/registry.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest"; +import { ClientRegistry, toResolver } from "./registry.js"; +import { makeConfig } from "./test-helpers.js"; + +function makeMockClient() { + return { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: "" }), + createBank: vi.fn().mockResolvedValue({}), + } as any; +} + +describe("toResolver", () => { + it("wraps a bare client so forAgent() always returns it", () => { + const client = makeMockClient(); + const resolver = toResolver(client); + expect(resolver.forAgent("build")).toBe(client); + expect(resolver.forAgent(undefined)).toBe(client); + expect(resolver.forAgent(null)).toBe(client); + }); + + it("passes through an existing ClientResolver unchanged", () => { + const client = makeMockClient(); + const resolver = { forAgent: () => client }; + expect(toResolver(resolver)).toBe(resolver); + }); +}); + +describe("ClientRegistry", () => { + it("returns the default client for the fallback token", () => { + const defaultClient = makeMockClient(); + const factory = vi.fn(); + const registry = new ClientRegistry({ + baseUrl: "https://api.test", + config: makeConfig({ hindsightApiToken: "static" }), + defaultClient, + clientFactory: factory, + }); + + expect(registry.forAgent(undefined)).toBe(defaultClient); + expect(registry.forAgent("anything")).toBe(defaultClient); // no per-agent map → fallback token + expect(factory).not.toHaveBeenCalled(); + }); + + it("builds and caches a per-agent client only when the agent has a distinct token", () => { + const defaultClient = makeMockClient(); + const buildClient = makeMockClient(); + const reviewClient = makeMockClient(); + const factory = vi.fn(); + factory.mockReturnValueOnce(buildClient).mockReturnValueOnce(reviewClient); + + const config = makeConfig({ + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key", "code-reviewer": "review-key" }, + }); + const registry = new ClientRegistry({ + baseUrl: "https://api.test", + config, + defaultClient, + clientFactory: factory, + }); + + expect(registry.forAgent("build")).toBe(buildClient); + expect(factory).toHaveBeenCalledWith({ baseUrl: "https://api.test", apiKey: "build-key" }); + + // Second call for the same agent reuses the cached client (no new construction). + expect(registry.forAgent("build")).toBe(buildClient); + expect(factory).toHaveBeenCalledTimes(1); + + // A different agent gets its own client. + expect(registry.forAgent("code-reviewer")).toBe(reviewClient); + expect(factory).toHaveBeenCalledWith({ + baseUrl: "https://api.test", + apiKey: "review-key", + }); + expect(factory).toHaveBeenCalledTimes(2); + + // An agent with no entry falls back to the default client (no construction). + expect(registry.forAgent("security-reviewer")).toBe(defaultClient); + expect(factory).toHaveBeenCalledTimes(2); + }); + + it("passes the resolved token as apiKey to the factory", () => { + const defaultClient = makeMockClient(); + const built = makeMockClient(); + const factory = vi.fn().mockReturnValue(built); + const registry = new ClientRegistry({ + baseUrl: "https://api.test", + config: makeConfig({ + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key" }, + }), + defaultClient, + clientFactory: factory, + }); + + registry.forAgent("build"); + expect(factory).toHaveBeenCalledWith({ baseUrl: "https://api.test", apiKey: "build-key" }); + }); + + it("shares one client across agents that map to the same token", () => { + const defaultClient = makeMockClient(); + const shared = makeMockClient(); + const factory = vi.fn().mockReturnValue(shared); + const registry = new ClientRegistry({ + baseUrl: "https://api.test", + config: makeConfig({ + hindsightApiToken: "static", + hindsightApiTokens: { "agent-a": "shared-key", "agent-b": "shared-key" }, + }), + defaultClient, + clientFactory: factory, + }); + + expect(registry.forAgent("agent-a")).toBe(shared); + expect(registry.forAgent("agent-b")).toBe(shared); // same token → same client + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("uses the default client when dynamicApiKey is disabled", () => { + const defaultClient = makeMockClient(); + const factory = vi.fn(); + const registry = new ClientRegistry({ + baseUrl: "https://api.test", + config: makeConfig({ + dynamicApiKey: false, + hindsightApiToken: "static", + hindsightApiTokens: { build: "build-key" }, + }), + defaultClient, + clientFactory: factory, + }); + + expect(registry.forAgent("build")).toBe(defaultClient); + expect(factory).not.toHaveBeenCalled(); + }); +}); diff --git a/hindsight-integrations/opencode/src/registry.ts b/hindsight-integrations/opencode/src/registry.ts new file mode 100644 index 000000000..51b422d32 --- /dev/null +++ b/hindsight-integrations/opencode/src/registry.ts @@ -0,0 +1,101 @@ +/** + * Per-agent HindsightClient registry. + * + * OpenCode plugins are loaded once per server process, but multiple agents + * (build, code-reviewer, custom agents, …) can drive sessions within that + * process. When `hindsightApiTokens` maps agent names to distinct API tokens, + * each agent must talk to Hindsight with its own `HindsightClient` instance. + * + * The registry lazily constructs and caches one `HindsightClient` per resolved + * token (so agents that share a token reuse a single client). A default client + * — built eagerly from the static `hindsightApiToken` / `agentName` fallback — + * is preserved so that legacy single-token setups (and the plugin tests that + * assert eager construction) keep working unchanged. + */ + +import { HindsightClient } from "@vectorize-io/hindsight-client"; +import type { HindsightConfig } from "./config.js"; +import { resolveApiKey } from "./config.js"; +import { Logger } from "./logger.js"; + +/** Anything that can resolve a `HindsightClient` for a given agent name. */ +export interface ClientResolver { + forAgent(agentName?: string | null): HindsightClient; +} + +/** + * Normalize a `HindsightClient | ClientResolver` into a `ClientResolver`. + * A bare `HindsightClient` (e.g. a mock in unit tests) is wrapped so that + * `forAgent()` always returns the same instance, regardless of agent — this + * keeps the existing call sites and tests unchanged. + */ +export function toResolver( + clientOrResolver: HindsightClient | ClientResolver +): ClientResolver { + const maybe = clientOrResolver as Partial; + if (typeof maybe.forAgent === "function") { + return clientOrResolver as ClientResolver; + } + const client = clientOrResolver as HindsightClient; + return { forAgent: () => client }; +} + +export interface ClientRegistryOptions { + baseUrl: string; + config: HindsightConfig; + /** Eagerly-constructed default client (constructed from the fallback token). */ + defaultClient: HindsightClient; + logger?: Logger; + /** Injectable for tests; defaults to the real constructor. */ + clientFactory?: (options: { baseUrl: string; apiKey?: string }) => HindsightClient; +} + +export class ClientRegistry implements ClientResolver { + private readonly baseUrl: string; + private readonly config: HindsightConfig; + private readonly defaultClient: HindsightClient; + private readonly logger: Logger; + private readonly clientFactory: (options: { + baseUrl: string; + apiKey?: string; + }) => HindsightClient; + /** Cache by resolved token so agents sharing a token share one client. */ + private readonly clientsByToken = new Map(); + + constructor(options: ClientRegistryOptions) { + this.baseUrl = options.baseUrl; + this.config = options.config; + this.defaultClient = options.defaultClient; + this.logger = options.logger ?? new Logger({ silent: true }); + this.clientFactory = + options.clientFactory ?? + ((opts) => new HindsightClient({ baseUrl: opts.baseUrl, apiKey: opts.apiKey })); + } + + /** + * Return the `HindsightClient` to use for `agentName`. When the resolved + * token equals the default client's token, the pre-built default client is + * returned (reference-stable), avoiding extra construction for the common + * single-token case. + */ + forAgent(agentName?: string | null): HindsightClient { + const token = resolveApiKey(this.config, agentName) ?? undefined; + const defaultToken = resolveApiKey(this.config) ?? undefined; + + if (token === defaultToken) { + return this.defaultClient; + } + + const cacheKey = token ?? "__no_token__"; + const existing = this.clientsByToken.get(cacheKey); + if (existing) return existing; + + const client = this.clientFactory({ baseUrl: this.baseUrl, apiKey: token }); + this.clientsByToken.set(cacheKey, client); + this.logger.info("Hindsight client created for agent", { + agent: agentName ?? "(unknown)", + authenticated: Boolean(token), + }); + return client; + } +} \ No newline at end of file diff --git a/hindsight-integrations/opencode/src/test-helpers.ts b/hindsight-integrations/opencode/src/test-helpers.ts index 9bb9b51d3..aae5087ab 100644 --- a/hindsight-integrations/opencode/src/test-helpers.ts +++ b/hindsight-integrations/opencode/src/test-helpers.ts @@ -20,6 +20,8 @@ export function makeConfig(overrides: Partial = {}): HindsightC retainMetadata: {}, hindsightApiUrl: "https://api.hindsight.vectorize.io", hindsightApiToken: null, + hindsightApiTokens: {}, + dynamicApiKey: true, bankId: null, bankIdPrefix: "", dynamicBankId: false, @@ -27,6 +29,7 @@ export function makeConfig(overrides: Partial = {}): HindsightC bankMission: "", retainMission: null, agentName: "opencode", + hindsightBankIds: {}, debug: false, ...overrides, }; diff --git a/hindsight-integrations/opencode/src/tools.test.ts b/hindsight-integrations/opencode/src/tools.test.ts index 4cc6801d2..4e3d6d91d 100644 --- a/hindsight-integrations/opencode/src/tools.test.ts +++ b/hindsight-integrations/opencode/src/tools.test.ts @@ -317,4 +317,70 @@ describe("createTools", () => { expect(client.recall.mock.calls[0][0]).toBe("fixed-bank"); expect(client.reflect.mock.calls[0][0]).toBe("fixed-bank"); }); + + describe("dynamic per-agent keys", () => { + it("routes each tool call to the client resolved for context.agent", async () => { + const buildClient = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: "build-answer" }), + createBank: vi.fn().mockResolvedValue({}), + } as any; + const reviewClient = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: "review-answer" }), + createBank: vi.fn().mockResolvedValue({}), + } as any; + const resolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? reviewClient : buildClient + ), + }; + + const tools = createTools(resolver, "bank", makeConfig()); + const buildCtx = { ...mockContext, agent: "build" }; + const reviewCtx = { ...mockContext, agent: "code-reviewer" }; + + await tools.hindsight_retain.execute({ content: "fact" }, buildCtx); + await tools.hindsight_recall.execute({ query: "q" }, reviewCtx); + await tools.hindsight_reflect.execute({ query: "q" }, reviewCtx); + + expect(resolver.forAgent).toHaveBeenCalledWith("build"); + expect(resolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(buildClient.retain).toHaveBeenCalledWith("bank", "fact", expect.anything()); + expect(reviewClient.recall).toHaveBeenCalledWith("bank", "q", expect.anything()); + expect(reviewClient.reflect).toHaveBeenCalledWith("bank", "q", expect.anything()); + expect(buildClient.recall).not.toHaveBeenCalled(); + expect(reviewClient.retain).not.toHaveBeenCalled(); + }); + + it("routes each tool call to the bank resolved for context.agent", async () => { + const client = { + retain: vi.fn().mockResolvedValue({}), + recall: vi.fn().mockResolvedValue({ results: [] }), + reflect: vi.fn().mockResolvedValue({ text: "" }), + createBank: vi.fn().mockResolvedValue({}), + } as any; + const bankResolver = { + forAgent: vi.fn((agent?: string | null) => + agent === "code-reviewer" ? "review-bank" : "build-bank" + ), + }; + + const tools = createTools(client, bankResolver, makeConfig()); + const buildCtx = { ...mockContext, agent: "build" }; + const reviewCtx = { ...mockContext, agent: "code-reviewer" }; + + await tools.hindsight_retain.execute({ content: "fact" }, buildCtx); + await tools.hindsight_recall.execute({ query: "q" }, reviewCtx); + await tools.hindsight_reflect.execute({ query: "q" }, reviewCtx); + + expect(bankResolver.forAgent).toHaveBeenCalledWith("build"); + expect(bankResolver.forAgent).toHaveBeenCalledWith("code-reviewer"); + expect(client.retain.mock.calls[0][0]).toBe("build-bank"); + expect(client.recall.mock.calls[0][0]).toBe("review-bank"); + expect(client.reflect.mock.calls[0][0]).toBe("review-bank"); + }); + }); }); diff --git a/hindsight-integrations/opencode/src/tools.ts b/hindsight-integrations/opencode/src/tools.ts index b875d226f..809b559cb 100644 --- a/hindsight-integrations/opencode/src/tools.ts +++ b/hindsight-integrations/opencode/src/tools.ts @@ -12,6 +12,8 @@ import type { HindsightConfig } from "./config.js"; import { formatMemories, formatCurrentTime } from "./content.js"; import { ensureBankMission } from "./bank.js"; import { Logger } from "./logger.js"; +import { toResolver, type ClientResolver } from "./registry.js"; +import { toBankResolver, type BankResolver } from "./bank.js"; export interface HindsightTools { hindsight_retain: ToolDefinition; @@ -23,12 +25,14 @@ export interface HindsightTools { } export function createTools( - client: HindsightClient, - bankId: string, + clientOrResolver: HindsightClient | ClientResolver, + bankIdOrResolver: string | BankResolver, config: HindsightConfig, missionsSet?: Set, logger: Logger = new Logger({ silent: true }) ): HindsightTools { + const resolver = toResolver(clientOrResolver); + const bankResolver = toBankResolver(bankIdOrResolver); const hindsight_retain = tool({ description: "Store information in long-term memory. Use this to remember important facts, " + @@ -43,7 +47,10 @@ export function createTools( .optional() .describe("Optional context about where this information came from."), }, - async execute(args) { + async execute(args, context) { + const agent = context?.agent; + const client = resolver.forAgent(agent); + const bankId = bankResolver.forAgent(agent); if (missionsSet) { await ensureBankMission(client, bankId, config, missionsSet, logger); } @@ -66,7 +73,10 @@ export function createTools( .string() .describe("Natural language search query. Be specific about what you need to know."), }, - async execute(args) { + async execute(args, context) { + const agent = context?.agent; + const client = resolver.forAgent(agent); + const bankId = bankResolver.forAgent(agent); const response = await client.recall(bankId, args.query, { budget: config.recallBudget as "low" | "mid" | "high", maxTokens: config.recallMaxTokens, @@ -95,7 +105,10 @@ export function createTools( .optional() .describe("Optional additional context to guide the reflection."), }, - async execute(args) { + async execute(args, context) { + const agent = context?.agent; + const client = resolver.forAgent(agent); + const bankId = bankResolver.forAgent(agent); if (missionsSet) { await ensureBankMission(client, bankId, config, missionsSet, logger); }