Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion opencode/index.d.mts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { createObsxaPlugin, createObsxaPlugin as default } from "obsxa/opencode";
export { createObsxaPlugin } from "../src/opencode.ts";
declare const _default: ReturnType<typeof import("../src/opencode.ts").createObsxaPlugin>;
Comment thread
oritwoen marked this conversation as resolved.
export default _default;
8 changes: 6 additions & 2 deletions opencode/index.mjs
Original file line number Diff line number Diff line change
@@ -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;
134 changes: 130 additions & 4 deletions src/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ObsxaPluginOptions {
projectName?: string;
maxInjectedObservations?: number;
maxInjectedChars?: number;
toolLoader?: () => Promise<typeof import("@opencode-ai/plugin/tool")>;
}

type PluginInput = {
Expand Down Expand Up @@ -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<typeof import("@opencode-ai/plugin/tool")>,
): Promise<Record<string, unknown> | 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<{
Expand Down Expand Up @@ -302,8 +423,14 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin {
const latestMessageBufferBySession = new Map<string, string>();
const hashCache = new Map<string, number>();
const sessionMessageObs = new Map<string, number>();
const obsxaTool = await createObsxaTool(obsxa, projectId, options?.toolLoader);

return {
tool: obsxaTool
? {
obsxa: obsxaTool,
}
: undefined,
destroy: async () => {
if (closed) return;
closed = true;
Expand Down Expand Up @@ -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(
`<obsxa-instruction>\n${AGENT_INSTRUCTION}\n</obsxa-instruction>`,
`<obsxa-instruction>\n${obsxaTool ? AGENT_TOOL_INSTRUCTION : AGENT_FALLBACK_INSTRUCTION}\n</obsxa-instruction>`,
);

const query =
Expand Down
99 changes: 92 additions & 7 deletions test/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ type ChatHook = NonNullable<Hooks["chat.message"]>;
type ToolHook = NonNullable<Hooks["tool.execute.after"]>;
type EventHook = NonNullable<Hooks["event"]>;
type SystemHook = NonNullable<Hooks["experimental.chat.system.transform"]>;
type ObsxaTool = {
execute: (args: Record<string, unknown>) => Promise<string>;
};

let trackedHooks: Hooks[] = [];

Expand All @@ -30,12 +33,15 @@ function chatInput(
sessionID: string,
messageID?: string,
agent?: string,
model?: unknown,
model?: Parameters<ChatHook>[0]["model"],
): Parameters<ChatHook>[0] {
return { sessionID, messageID, agent, model };
}

function chatOutput(message: unknown, parts: unknown[]): Parameters<ChatHook>[1] {
function chatOutput(
message: unknown,
parts: Parameters<ChatHook>[1]["parts"],
): Parameters<ChatHook>[1] {
return { message, parts };
}

Expand All @@ -56,14 +62,28 @@ function toolOutput(title: string, output: string, metadata: unknown): Parameter
return { title, output, metadata };
}

function eventInput(type: string, properties: unknown): Parameters<EventHook>[0] {
function eventInput(type: string, properties: Record<string, unknown>): Parameters<EventHook>[0] {
return { event: { type, properties } };
}

function systemInput(sessionID?: string): Parameters<SystemHook>[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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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("<obsxa-instruction>"))!;
expect(instructionEntry.length).toBeLessThan(500);
});

Expand Down Expand Up @@ -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("<obsxa-instruction>"))).toBe(true);
});

it("does NOT throw when FTS search fails (simulated by empty query)", async () => {
Expand Down Expand Up @@ -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("<obsxa-instruction>"))).toBe(true);
expect(
injected.includes("Bitcoin") || injected.includes("key patterns") || injected.includes("RNG"),
).toBe(true);
Expand Down
Loading