diff --git a/opencode/index.d.mts b/opencode/index.d.mts index 6713f3e..f4a3ceb 100644 --- a/opencode/index.d.mts +++ b/opencode/index.d.mts @@ -1 +1,3 @@ -export { createObsxaPlugin, createObsxaPlugin as default } from "obsxa/opencode"; +export { createObsxaPlugin } from "../src/opencode.ts"; +declare const _default: ReturnType; +export default _default; diff --git a/opencode/index.mjs b/opencode/index.mjs index cef156d..82400ec 100644 --- a/opencode/index.mjs +++ b/opencode/index.mjs @@ -1,2 +1,6 @@ -export { createObsxaPlugin } from "obsxa/opencode"; -export { createObsxaPlugin as default } from "obsxa/opencode"; +import { createObsxaPlugin } from "obsxa/opencode"; + +const plugin = createObsxaPlugin(); + +export { createObsxaPlugin }; +export default plugin; diff --git a/src/opencode.ts b/src/opencode.ts index 3217f29..90d003e 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -9,6 +9,7 @@ export interface ObsxaPluginOptions { projectName?: string; maxInjectedObservations?: number; maxInjectedChars?: number; + toolLoader?: () => Promise; } type PluginInput = { @@ -177,8 +178,128 @@ async function findByHash( return found.id; } -const AGENT_INSTRUCTION = - "When you notice patterns, anomalies, correlations, or interesting measurements during your work, record them using the observation tool for future reference. Types: pattern, anomaly, measurement, correlation, artifact."; +const AGENT_TOOL_INSTRUCTION = + "When you notice patterns, anomalies, correlations, or interesting measurements during your work, record them using the obsxa tool (operation=add) for future reference. Types: pattern, anomaly, measurement, correlation, artifact."; + +const AGENT_FALLBACK_INSTRUCTION = + "When you notice patterns, anomalies, correlations, or interesting measurements during your work, record them for future reference. Types: pattern, anomaly, measurement, correlation, artifact."; + +async function createObsxaTool( + obsxa: ObsxaInstance, + defaultProjectId: string, + toolLoader?: () => Promise, +): Promise | undefined> { + try { + const pluginToolModule = await (toolLoader ?? (() => import("@opencode-ai/plugin/tool")))(); + const pluginTool = pluginToolModule.tool; + const schema = pluginTool.schema; + + return pluginTool({ + description: + "Manage obsxa observations: add/get/list/search/stats. Defaults to current OpenCode project when projectId is omitted.", + args: { + operation: schema.enum(["add", "get", "list", "search", "stats"]), + projectId: schema.string().optional(), + id: schema.number().optional(), + title: schema.string().optional(), + description: schema.string().optional(), + type: schema + .enum(["pattern", "anomaly", "measurement", "correlation", "artifact"]) + .optional(), + source: schema.string().optional(), + sourceType: schema + .enum(["experiment", "manual", "scan", "computation", "external"]) + .optional(), + confidence: schema.number().min(0).max(100).optional(), + query: schema.string().optional(), + status: schema.enum(["active", "promoted", "dismissed", "archived"]).optional(), + limit: schema.number().optional(), + }, + async execute(args) { + const operation = String(args.operation ?? ""); + const projectId = + typeof args.projectId === "string" && args.projectId.length > 0 + ? args.projectId + : defaultProjectId; + + let result: unknown; + switch (operation) { + case "add": { + const title = typeof args.title === "string" ? args.title : ""; + const source = typeof args.source === "string" ? args.source : "opencode"; + if (!title) throw new Error("obsxa tool: 'title' is required for add"); + result = await obsxa.observation.add({ + projectId, + title, + description: typeof args.description === "string" ? args.description : undefined, + type: + typeof args.type === "string" + ? (args.type as + | "pattern" + | "anomaly" + | "measurement" + | "correlation" + | "artifact") + : undefined, + source, + sourceType: + typeof args.sourceType === "string" + ? (args.sourceType as + | "experiment" + | "manual" + | "scan" + | "computation" + | "external") + : undefined, + confidence: typeof args.confidence === "number" ? args.confidence : undefined, + }); + break; + } + case "get": { + if (typeof args.id !== "number") + throw new Error("obsxa tool: 'id' is required for get"); + const observation = await obsxa.observation.get(args.id); + result = observation && observation.projectId === projectId ? observation : null; + break; + } + case "list": { + result = await obsxa.observation.list(projectId, { + status: + typeof args.status === "string" + ? (args.status as "active" | "promoted" | "dismissed" | "archived") + : undefined, + }); + break; + } + case "search": { + if (typeof args.query !== "string" || args.query.length === 0) { + throw new Error("obsxa tool: 'query' is required for search"); + } + result = await obsxa.search.search( + args.query, + projectId, + typeof args.limit === "number" ? args.limit : undefined, + ); + break; + } + case "stats": { + result = await obsxa.analysis.stats(projectId); + break; + } + default: + throw new Error( + "obsxa tool: unsupported operation; expected one of add/get/list/search/stats", + ); + } + + return JSON.stringify(result, null, 2); + }, + }); + } catch (error) { + logHookError("tool.init", error); + return undefined; + } +} function formatObservations( results: Array<{ @@ -302,8 +423,14 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const latestMessageBufferBySession = new Map(); const hashCache = new Map(); const sessionMessageObs = new Map(); + const obsxaTool = await createObsxaTool(obsxa, projectId, options?.toolLoader); return { + tool: obsxaTool + ? { + obsxa: obsxaTool, + } + : undefined, destroy: async () => { if (closed) return; closed = true; @@ -546,9 +673,8 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const maxObs = options?.maxInjectedObservations ?? 10; const maxChars = options?.maxInjectedChars ?? 2000; - // Always push agent instruction sysOutput.system.push( - `\n${AGENT_INSTRUCTION}\n`, + `\n${obsxaTool ? AGENT_TOOL_INSTRUCTION : AGENT_FALLBACK_INSTRUCTION}\n`, ); const query = diff --git a/test/opencode.test.ts b/test/opencode.test.ts index 033f7ea..9b16e03 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -10,6 +10,9 @@ type ChatHook = NonNullable; type ToolHook = NonNullable; type EventHook = NonNullable; type SystemHook = NonNullable; +type ObsxaTool = { + execute: (args: Record) => Promise; +}; let trackedHooks: Hooks[] = []; @@ -30,12 +33,15 @@ function chatInput( sessionID: string, messageID?: string, agent?: string, - model?: unknown, + model?: Parameters[0]["model"], ): Parameters[0] { return { sessionID, messageID, agent, model }; } -function chatOutput(message: unknown, parts: unknown[]): Parameters[1] { +function chatOutput( + message: unknown, + parts: Parameters[1]["parts"], +): Parameters[1] { return { message, parts }; } @@ -56,7 +62,7 @@ function toolOutput(title: string, output: string, metadata: unknown): Parameter return { title, output, metadata }; } -function eventInput(type: string, properties: unknown): Parameters[0] { +function eventInput(type: string, properties: Record): Parameters[0] { return { event: { type, properties } }; } @@ -64,6 +70,20 @@ function systemInput(sessionID?: string): Parameters[0] { return { sessionID, model: null }; } +function getObsxaTool(hooks: Hooks): ObsxaTool { + const tool = hooks.tool?.obsxa; + if (!tool || typeof tool !== "object") { + throw new Error("obsxa tool unavailable"); + } + + const candidate = tool as { execute?: unknown }; + if (typeof candidate.execute !== "function") { + throw new Error("obsxa tool execute unavailable"); + } + + return { execute: candidate.execute as ObsxaTool["execute"] }; +} + describe("createObsxaPlugin", () => { afterEach(async () => { await cleanupTrackedHooks(); @@ -489,6 +509,38 @@ describe("tool.execute.after hook", () => { }); }); +describe("obsxa tool", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-obsxa-tool-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("get returns null for observations from another project", async () => { + await getHooks(dbPath, "project-a"); + const hooksB = await getHooks(dbPath, "project-b"); + + const obsxa = await createObsxa({ db: dbPath }); + const observation = await obsxa.observation.add({ + projectId: "project-a", + title: "Project A only observation", + source: "test", + }); + await obsxa.close(); + + const result = await getObsxaTool(hooksB).execute({ operation: "get", id: observation.id }); + + expect(JSON.parse(result)).toBeNull(); + }); +}); + describe("event hook", () => { let dbDir: string; let dbPath: string; @@ -585,7 +637,40 @@ describe("system.transform hook", () => { const output = { system: [] as string[] }; await hooks["experimental.chat.system.transform"]!(systemInput(), output); expect(output.system.length).toBeGreaterThanOrEqual(1); - expect(output.system.some((s) => s.includes("observation tool"))).toBe(true); + expect(output.system.join(" ")).toContain("record them"); + }); + + it("mentions obsxa tool only when tool is registered", async () => { + const hooks = await getHooks(dbPath); + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + + const hasObsxaTool = + hooks.tool !== undefined && Object.prototype.hasOwnProperty.call(hooks.tool, "obsxa"); + expect(output.system.some((s) => s.includes("obsxa tool"))).toBe(hasObsxaTool); + }); + + it("falls back to generic instruction when tool registration fails", async () => { + const plugin = createObsxaPlugin({ + db: dbPath, + toolLoader: async () => { + throw new Error("missing plugin tool"); + }, + }); + const hooks = await plugin({ + project: { id: "test-project" }, + directory: "/tmp", + worktree: "/tmp", + }); + trackedHooks.push(hooks); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + + const joined = output.system.join(" "); + expect(hooks.tool).toBeUndefined(); + expect(joined).toContain("record them for future reference"); + expect(joined).not.toContain("obsxa tool"); }); it("instruction mentions observation types", async () => { @@ -602,7 +687,7 @@ describe("system.transform hook", () => { const hooks = await getHooks(dbPath); const output = { system: [] as string[] }; await hooks["experimental.chat.system.transform"]!(systemInput(), output); - const instructionEntry = output.system.find((s) => s.includes("observation tool"))!; + const instructionEntry = output.system.find((s) => s.includes(""))!; expect(instructionEntry.length).toBeLessThan(500); }); @@ -717,7 +802,7 @@ describe("system.transform hook", () => { await expect( hooks["experimental.chat.system.transform"]!(systemInput(), output), ).resolves.toBeUndefined(); - expect(output.system.some((s) => s.includes("observation tool"))).toBe(true); + expect(output.system.some((s) => s.includes(""))).toBe(true); }); it("does NOT throw when FTS search fails (simulated by empty query)", async () => { @@ -806,7 +891,7 @@ describe("full lifecycle integration", () => { const injected = output.system.join(" "); expect(output.system.length).toBeGreaterThan(0); - expect(output.system.some((s) => s.includes("observation tool"))).toBe(true); + expect(output.system.some((s) => s.includes(""))).toBe(true); expect( injected.includes("Bitcoin") || injected.includes("key patterns") || injected.includes("RNG"), ).toBe(true);