From 0d14a5f5038951e1661d8230e44623aa16191a8b Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:18:02 +0100 Subject: [PATCH 01/19] feat: add opencode plugin and xdg db defaults --- CHANGELOG.md | 7 +- build.config.ts | 7 +- package.json | 9 + src/ai.ts | 5 +- src/commands/_db.ts | 4 +- src/commands/backup.ts | 22 +- src/core/db-path.ts | 25 ++ src/core/observation.ts | 9 + src/index.ts | 20 +- src/opencode.ts | 383 ++++++++++++++++++++ src/types.ts | 4 +- test/db-path.test.ts | 64 ++++ test/opencode.test.ts | 772 ++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1308 insertions(+), 23 deletions(-) create mode 100644 src/core/db-path.ts create mode 100644 src/opencode.ts create mode 100644 test/db-path.test.ts create mode 100644 test/opencode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5350b57..fbe782c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog +## Unreleased -## v0.0.2 +### ⚠️ Notes + +- **db path:** `createObsxa()` now defaults to an XDG-compliant data path (`~/.local/share/obsxa/obsxa.db` on Linux) instead of `./obsxa.db`. Pass `db: "./obsxa.db"` explicitly to keep legacy location. +## v0.0.2 ### 🏡 Chore @@ -11,4 +15,3 @@ ### ❤️ Contributors - Ori ([@oritwoen](https://github.com/oritwoen)) - diff --git a/build.config.ts b/build.config.ts index 9fa97ab..b993907 100644 --- a/build.config.ts +++ b/build.config.ts @@ -5,5 +5,10 @@ export default defineBuildConfig({ rollup: { emitCJS: false, }, - entries: [{ type: "bundle", input: ["./src/index.ts", "./src/cli.ts", "./src/ai.ts"] }], + entries: [ + { + type: "bundle", + input: ["./src/index.ts", "./src/cli.ts", "./src/ai.ts", "./src/opencode.ts"], + }, + ], }); diff --git a/package.json b/package.json index ffc79ac..ddab3de 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "./ai": { "types": "./dist/ai.d.mts", "import": "./dist/ai.mjs" + }, + "./opencode": { + "types": "./dist/opencode.d.mts", + "import": "./dist/opencode.mjs" } }, "scripts": { @@ -51,6 +55,7 @@ "drizzle-orm": "^0.44.0" }, "devDependencies": { + "@opencode-ai/plugin": "latest", "@types/better-sqlite3": "^7.6.11", "@types/node": "^25.3.0", "@typescript/native-preview": "latest", @@ -65,10 +70,14 @@ "zod": "^4.3.6" }, "peerDependencies": { + "@opencode-ai/plugin": "*", "ai": ">=6.0.0", "zod": ">=4.0.0" }, "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + }, "ai": { "optional": true }, diff --git a/src/ai.ts b/src/ai.ts index 0f4017f..3dd2659 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,12 +1,11 @@ -import { isAbsolute } from "node:path"; import { tool } from "ai"; import { z } from "zod/v4"; +import { getDefaultDbPath } from "./core/db-path.ts"; import { createObsxa } from "./index.ts"; import type { ObsxaInstance } from "./index.ts"; function sanitizeDbPath(path?: string): string { - const dbPath = path ?? "./obsxa.db"; - if (isAbsolute(dbPath)) throw new Error("Absolute database paths are not allowed"); + const dbPath = path ?? getDefaultDbPath(); if (dbPath.includes("..")) throw new Error("Database path must not contain '..'"); if (!dbPath.endsWith(".db")) throw new Error("Database path must end with '.db'"); return dbPath; diff --git a/src/commands/_db.ts b/src/commands/_db.ts index 25f3c8f..f5daded 100644 --- a/src/commands/_db.ts +++ b/src/commands/_db.ts @@ -3,12 +3,12 @@ import { consola } from "consola"; import { createObsxa } from "../index.ts"; export const dbArgs = { - db: { type: "string" as const, description: "Path to SQLite database", default: "./obsxa.db" }, + db: { type: "string" as const, description: "Path to SQLite database" }, json: { type: "boolean" as const, description: "Output as JSON", default: false }, toon: { type: "boolean" as const, description: "Output as TOON", default: false }, }; -export function open(dbPath: string) { +export function open(dbPath?: string) { return createObsxa({ db: dbPath }); } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 547230c..22f8e2f 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -1,6 +1,8 @@ import { defineCommand } from "citty"; import { consola } from "consola"; import { backupDatabase, restoreDatabase } from "../backup.ts"; +import { output } from "./_db.ts"; +import { getDefaultDbPath } from "../core/db-path.ts"; export default defineCommand({ meta: { name: "backup", description: "Backup or restore obsxa SQLite database files" }, @@ -10,15 +12,19 @@ export default defineCommand({ defineCommand({ meta: { name: "create", description: "Create database backup (db, wal, shm)" }, args: { - db: { type: "string", default: "./obsxa.db", description: "Path to SQLite database" }, + db: { + type: "string", + description: "Path to SQLite database", + }, out: { type: "string", description: "Backup base path (without -wal/-shm suffixes)" }, json: { type: "boolean", default: false, description: "Output as JSON" }, + toon: { type: "boolean", default: false, description: "Output as TOON" }, }, run({ args }) { try { - const result = backupDatabase(args.db, args.out); - if (args.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + const result = backupDatabase(args.db ?? getDefaultDbPath(), args.out); + if (args.json || args.toon) { + output(result, args.toon); return; } consola.success(`Backup created: ${result.basePath}`); @@ -37,7 +43,6 @@ export default defineCommand({ args: { db: { type: "string", - default: "./obsxa.db", description: "Target SQLite database path", }, from: { @@ -46,12 +51,13 @@ export default defineCommand({ description: "Backup base path to restore from", }, json: { type: "boolean", default: false, description: "Output as JSON" }, + toon: { type: "boolean", default: false, description: "Output as TOON" }, }, run({ args }) { try { - const result = restoreDatabase(args.db, args.from); - if (args.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + const result = restoreDatabase(args.db ?? getDefaultDbPath(), args.from); + if (args.json || args.toon) { + output(result, args.toon); return; } consola.success(`Database restored from: ${result.restoredFrom}`); diff --git a/src/core/db-path.ts b/src/core/db-path.ts new file mode 100644 index 0000000..a062e8a --- /dev/null +++ b/src/core/db-path.ts @@ -0,0 +1,25 @@ +import { homedir } from "node:os"; +import { join, win32 } from "node:path"; + +export function getDefaultDbPath( + env: NodeJS.ProcessEnv = process.env, + home = homedir(), + platform: NodeJS.Platform = process.platform, +): string { + if (!home || home.trim().length === 0) { + throw new Error("Home directory must not be empty"); + } + + const xdgDataHome = env.XDG_DATA_HOME?.trim(); + const fallbackDataHome = + platform === "win32" + ? env.LOCALAPPDATA?.trim() || win32.join(home, "AppData", "Local") + : platform === "darwin" + ? join(home, "Library", "Application Support") + : join(home, ".local", "share"); + + const dataHome = xdgDataHome && xdgDataHome.length > 0 ? xdgDataHome : fallbackDataHome; + return platform === "win32" + ? win32.join(dataHome, "obsxa", "obsxa.db") + : join(dataHome, "obsxa", "obsxa.db"); +} diff --git a/src/core/observation.ts b/src/core/observation.ts index 04704a6..0fa045a 100644 --- a/src/core/observation.ts +++ b/src/core/observation.ts @@ -121,6 +121,15 @@ export function createObservationStore(db: ObsxaDB) { return row ? toObservation(row) : null; }, + getByInputHash(projectId: string, inputHash: string): Observation | null { + const row = db + .select() + .from(observations) + .where(and(eq(observations.projectId, projectId), eq(observations.inputHash, inputHash))) + .get(); + return row ? toObservation(row) : null; + }, + list( projectId: string, opts?: { diff --git a/src/index.ts b/src/index.ts index 66c35d8..e05e96c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createAnalysisStore } from "./core/analysis.ts"; import { createClusterStore } from "./core/cluster.ts"; +import { getDefaultDbPath } from "./core/db-path.ts"; import { createDedupStore } from "./core/dedup.ts"; import { createObservationStore } from "./core/observation.ts"; import { createProjectStore } from "./core/project.ts"; @@ -51,6 +52,9 @@ export type { UpdateObservation, } from "./types.ts"; +export { createObsxaPlugin } from "./opencode.ts"; +export type { ObsxaPluginOptions } from "./opencode.ts"; + function findMigrationsFolder(): string { const start = dirname(fileURLToPath(import.meta.url)); let current = start; @@ -123,6 +127,7 @@ CREATE INDEX IF NOT EXISTS idx_observations_status ON observations(status); CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type); CREATE INDEX IF NOT EXISTS idx_observations_source_type ON observations(source_type); CREATE INDEX IF NOT EXISTS idx_observations_triage ON observations(triage_score); +CREATE INDEX IF NOT EXISTS idx_observations_project_input_hash ON observations(project_id, input_hash); CREATE INDEX IF NOT EXISTS idx_rel_from ON observation_relations(from_observation_id); CREATE INDEX IF NOT EXISTS idx_rel_to ON observation_relations(to_observation_id); CREATE INDEX IF NOT EXISTS idx_rel_type ON observation_relations(type); @@ -189,14 +194,19 @@ export interface ObsxaInstance { * obsxa.close() * ``` */ -export function createObsxa(options: ObsxaOptions = { db: "./obsxa.db" }): ObsxaInstance { +export function createObsxa(options: ObsxaOptions = {}): ObsxaInstance { + const dbPath: string = options.db ?? getDefaultDbPath(); const resolved = { autoMigrate: options.autoMigrate ?? true, autoBackup: options.autoBackup ?? true, backupDir: options.backupDir, }; - const sqlite = new Database(options.db); + if (dbPath !== ":memory:") { + mkdirSync(dirname(dbPath), { recursive: true }); + } + + const sqlite = new Database(dbPath); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = ON"); @@ -215,9 +225,9 @@ export function createObsxa(options: ObsxaOptions = { db: "./obsxa.db" }): Obsxa ); } - if (needsMigration && shouldBackup(options.db, resolved.autoBackup)) { - const backupPath = makeBackupPath(options.db, resolved.backupDir); - backupDatabase(options.db, backupPath); + if (needsMigration && shouldBackup(dbPath, resolved.autoBackup)) { + const backupPath = makeBackupPath(dbPath, resolved.backupDir); + backupDatabase(dbPath, backupPath); } const db = drizzle(sqlite); diff --git a/src/opencode.ts b/src/opencode.ts new file mode 100644 index 0000000..1b06cce --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,383 @@ +import { createHash } from "node:crypto"; +import { getDefaultDbPath } from "./core/db-path.ts"; +import { createObsxa } from "./index.ts"; +import type { ObsxaInstance } from "./index.ts"; + +export interface ObsxaPluginOptions { + db?: string; + projectId?: string; + projectName?: string; + maxInjectedObservations?: number; + maxInjectedChars?: number; +} + +// Inline OpenCode plugin types (no runtime dependency on @opencode-ai/plugin) +// Source: /tmp/opencode/packages/plugin/src/index.ts +type PluginInput = { + project: { id: string; [key: string]: unknown }; + directory: string; + worktree: string; + [key: string]: unknown; +}; + +type Hooks = { + destroy?: () => Promise; + "chat.message"?: ( + input: { sessionID: string; agent?: string; model?: unknown; messageID?: string }, + output: { message: unknown; parts: unknown[] }, + ) => Promise; + "tool.execute.after"?: ( + input: { tool: string; sessionID: string; callID: string; args: unknown }, + output: { title: string; output: string; metadata: unknown }, + ) => Promise; + event?: (input: { event: { type: string; properties: unknown } }) => Promise; + "experimental.chat.system.transform"?: ( + input: { sessionID?: string; model: unknown }, + output: { system: string[] }, + ) => Promise; +}; + +export type Plugin = (input: PluginInput) => Promise; + +const SKIP_TOOLS = new Set([ + "read", + "grep", + "glob", + "list_directory", + "lsp_diagnostics", + "lsp_find_references", + "lsp_goto_definition", + "lsp_symbols", + "lsp_prepare_rename", +]); + +const TRACKED_EVENTS: Record = { + "file.edited": "artifact", + "session.created": "pattern", + "session.idle": "measurement", + "command.executed": "correlation", +}; + +const MAX_HASH_CACHE_SIZE = 2000; +const MAX_SESSION_CACHE_SIZE = 1000; + +function getCacheValue(cache: Map, key: string): number | undefined { + const value = cache.get(key); + if (value === undefined) return undefined; + cache.delete(key); + cache.set(key, value); + return value; +} + +function setCacheValue( + cache: Map, + key: string, + value: number, + maxSize: number, +): void { + if (cache.has(key)) { + cache.delete(key); + } + cache.set(key, value); + + if (cache.size > maxSize) { + const oldestKey = cache.keys().next().value as string | undefined; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } +} + +function computeInputHash(payload: string, collector: string, projectId: string): string { + return createHash("sha256") + .update(JSON.stringify({ payload, collector, projectId })) + .digest("hex"); +} + +function findByHash( + obsxa: ObsxaInstance, + projectId: string, + hash: string, + cache: Map, +): number | undefined { + const cached = getCacheValue(cache, hash); + if (cached !== undefined) return cached; + const found = obsxa.observation.getByInputHash(projectId, hash); + if (!found) return undefined; + setCacheValue(cache, hash, found.id, MAX_HASH_CACHE_SIZE); + 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."; + +function formatObservations( + results: Array<{ + observation: { title: string; type: string; confidence: number; frequency: number }; + }>, + maxChars: number, +): string { + const lines = results.map( + (r) => + `- [${r.observation.type}] ${r.observation.title} (${r.observation.confidence}%, seen ${r.observation.frequency}x)`, + ); + let output = "## Recent Observations\n" + lines.join("\n"); + if (output.length > maxChars) { + const chars = Array.from(output); + const truncated = chars.slice(0, Math.max(0, maxChars - 3)).join(""); + const lastNewline = truncated.lastIndexOf("\n"); + output = (lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated) + "..."; + } + return output; +} + +function logHookError(scope: string, err: unknown): void { + console.warn(`[obsxa] ${scope} hook error`, err); +} + +export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { + return async (input: PluginInput): Promise => { + const db = options?.db ?? getDefaultDbPath(); + const obsxa = createObsxa({ db }); + let closed = false; + + const projectId = options?.projectId ?? input.project.id; + const projectName = options?.projectName ?? projectId; + + if (!obsxa.project.get(projectId)) { + obsxa.project.add({ id: projectId, name: projectName }); + } + + let latestMessageBuffer = ""; + const hashCache = new Map(); + // sessionID -> message observation ID (for derived_from relations) + const sessionMessageObs = new Map(); + + return { + destroy: async () => { + if (closed) return; + closed = true; + hashCache.clear(); + sessionMessageObs.clear(); + latestMessageBuffer = ""; + try { + obsxa.close(); + } catch (err) { + logHookError("destroy", err); + } + }, + + "chat.message": async (msgInput, msgOutput) => { + try { + if (closed) return; + const output = msgOutput as { message: unknown; parts: unknown[] } | null; + if (!output) return; + + const parts = (output.parts ?? []) as Array<{ + type: string; + text?: string; + content?: string; + }>; + const text = parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? p.content ?? "") + .join(" ") + .trim(); + + if (text.length < 20) return; + + latestMessageBuffer = text; + + const msgObj = output.message as { summary?: { title?: string } } | null | undefined; + const title = (msgObj?.summary?.title ?? text.slice(0, 100)).slice(0, 200); + + const collector = "opencode:chat.message"; + const hash = computeInputHash(text, collector, projectId); + + const existingId = findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + obsxa.observation.incrementFrequency(existingId); + return; + } + + const sourceRef = `session:${msgInput.sessionID ?? "unknown"}:message:${msgInput.messageID ?? "unknown"}`; + const obs = obsxa.observation.add({ + projectId, + title, + description: text.length > 200 ? text.slice(0, 500) : undefined, + type: "pattern", + source: msgInput.agent ?? "user", + sourceType: "manual", + collector, + sourceRef, + inputHash: hash, + context: JSON.stringify({ + sessionID: msgInput.sessionID, + agent: msgInput.agent, + model: msgInput.model, + messageID: msgInput.messageID, + }), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + if (msgInput.sessionID) { + setCacheValue(sessionMessageObs, msgInput.sessionID, obs.id, MAX_SESSION_CACHE_SIZE); + } + } catch (err) { + logHookError("chat.message", err); + } + }, + + "tool.execute.after": async (toolInput, toolOutput) => { + try { + if (closed) return; + if (SKIP_TOOLS.has(toolInput.tool)) return; + + const title = (toolOutput.title || toolInput.tool).slice(0, 200); + const collector = "opencode:tool.execute.after"; + const dedupPayload = `${toolOutput.title || toolInput.tool}\n${String(toolOutput.output ?? "")}`; + const hash = computeInputHash(dedupPayload, collector, projectId); + + const existingId = findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + obsxa.observation.incrementFrequency(existingId); + return; + } + + const sourceRef = `session:${toolInput.sessionID}:call:${toolInput.callID}`; + const description = toolOutput.output ? toolOutput.output.slice(0, 500) : undefined; + + const obs = obsxa.observation.add({ + projectId, + title, + description, + type: "measurement", + source: toolInput.tool, + sourceType: "computation", + collector, + sourceRef, + inputHash: hash, + context: JSON.stringify({ + tool: toolInput.tool, + callID: toolInput.callID, + sessionID: toolInput.sessionID, + }), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + + // Create derived_from relation to message observation if exists for this session + const msgObsId = getCacheValue(sessionMessageObs, toolInput.sessionID); + if (msgObsId !== undefined) { + try { + obsxa.relation.add({ + fromObservationId: obs.id, + toObservationId: msgObsId, + type: "derived_from", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const isConstraintViolation = + message.includes("UNIQUE constraint") || message.includes("SQLITE_CONSTRAINT"); + if (!isConstraintViolation) { + logHookError("tool.execute.after.relation", err); + } + } + } + } catch (err) { + logHookError("tool.execute.after", err); + } + }, + + event: async (evtInput) => { + try { + if (closed) return; + const evt = evtInput.event as { type: string; properties: Record }; + const obsType = TRACKED_EVENTS[evt.type]; + if (!obsType) return; + + const props = evt.properties ?? {}; + + let title: string; + let source: string; + if (evt.type === "file.edited") { + const file = (props.file as string) ?? "unknown"; + title = `File edited: ${file}`; + source = file; + } else if (evt.type === "command.executed") { + const name = (props.name as string) ?? "unknown"; + title = `Command executed: ${name}`; + source = name; + } else if (evt.type === "session.created") { + const info = + typeof props.info === "object" && props.info !== null + ? (props.info as Record) + : null; + const sessionId = typeof info?.id === "string" ? info.id : "unknown"; + title = `Session created: ${sessionId}`; + source = sessionId; + } else { + title = `Event: ${evt.type}`; + source = evt.type; + } + + title = title.slice(0, 200); + const collector = `opencode:event:${evt.type}`; + const hash = computeInputHash( + `${evt.type}:${JSON.stringify(props)}`, + collector, + projectId, + ); + + const existingId = findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + obsxa.observation.incrementFrequency(existingId); + return; + } + + const obs = obsxa.observation.add({ + projectId, + title, + type: obsType, + source, + sourceType: "external", + collector, + inputHash: hash, + context: JSON.stringify(props), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + } catch (err) { + logHookError("event", err); + } + }, + + "experimental.chat.system.transform": async (_sysInput, sysOutput) => { + try { + if (closed) return; + if (!sysOutput || !Array.isArray(sysOutput.system)) return; + const maxObs = options?.maxInjectedObservations ?? 10; + const maxChars = options?.maxInjectedChars ?? 2000; + + // Always push agent instruction + sysOutput.system.push(`\n${AGENT_INSTRUCTION}\n`); + + // Use buffered message text or fall back to project name + const query = latestMessageBuffer || projectName; + + // Cross-project search: pass undefined projectId + const results = obsxa.search.search(query, undefined, maxObs); + + if (results.length > 0) { + const formatted = formatObservations(results, maxChars); + sysOutput.system.push(`\n${formatted}\n`); + } + } catch (err) { + logHookError("system.transform", err); + } + }, + }; + }; +} + +export default createObsxaPlugin; diff --git a/src/types.ts b/src/types.ts index 9027e2e..1f7b9e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ /** Configuration for {@link createObsxa}. */ export interface ObsxaOptions { - /** Path to SQLite database file, or `:memory:` for in-memory. */ - db: string; + /** Path to SQLite database file, or `:memory:` for in-memory. Defaults to XDG data directory. */ + db?: string; /** Run schema migrations on open. @default true */ autoMigrate?: boolean; /** Back up database before migration. @default true */ diff --git a/test/db-path.test.ts b/test/db-path.test.ts new file mode 100644 index 0000000..fde07f4 --- /dev/null +++ b/test/db-path.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { getDefaultDbPath } from "../src/core/db-path.ts"; + +describe("getDefaultDbPath", () => { + it("uses XDG_DATA_HOME when present", () => { + const path = getDefaultDbPath( + { XDG_DATA_HOME: "/tmp/xdg-data" } as NodeJS.ProcessEnv, + "/home/test", + "linux", + ); + expect(path).toBe("/tmp/xdg-data/obsxa/obsxa.db"); + }); + + it("falls back to HOME/.local/share when XDG_DATA_HOME is missing", () => { + const path = getDefaultDbPath({} as NodeJS.ProcessEnv, "/home/test", "linux"); + expect(path).toBe("/home/test/.local/share/obsxa/obsxa.db"); + }); + + it("treats blank XDG_DATA_HOME as missing", () => { + const path = getDefaultDbPath( + { XDG_DATA_HOME: " " } as NodeJS.ProcessEnv, + "/home/test", + "linux", + ); + expect(path).toBe("/home/test/.local/share/obsxa/obsxa.db"); + }); + + it("uses macOS Application Support fallback", () => { + const path = getDefaultDbPath({} as NodeJS.ProcessEnv, "/Users/test", "darwin"); + expect(path).toBe("/Users/test/Library/Application Support/obsxa/obsxa.db"); + }); + + it("uses LOCALAPPDATA on Windows", () => { + const path = getDefaultDbPath( + { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } as NodeJS.ProcessEnv, + "C:\\Users\\test", + "win32", + ); + expect(path).toBe("C:\\Users\\test\\AppData\\Local\\obsxa\\obsxa.db"); + }); + + it("falls back to AppData Local on Windows when LOCALAPPDATA is missing", () => { + const path = getDefaultDbPath({} as NodeJS.ProcessEnv, "C:\\Users\\test", "win32"); + expect(path).toBe("C:\\Users\\test\\AppData\\Local\\obsxa\\obsxa.db"); + }); + + it("uses XDG_DATA_HOME on Windows when present", () => { + const path = getDefaultDbPath( + { + XDG_DATA_HOME: "D:\\xdg-data", + LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local", + } as NodeJS.ProcessEnv, + "C:\\Users\\test", + "win32", + ); + expect(path).toBe("D:\\xdg-data\\obsxa\\obsxa.db"); + }); + + it("throws when home directory is empty", () => { + expect(() => getDefaultDbPath({} as NodeJS.ProcessEnv, "", "linux")).toThrow( + "Home directory must not be empty", + ); + }); +}); diff --git a/test/opencode.test.ts b/test/opencode.test.ts new file mode 100644 index 0000000..af4229d --- /dev/null +++ b/test/opencode.test.ts @@ -0,0 +1,772 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createObsxaPlugin } from "../src/opencode.ts"; +import { createObsxa } from "../src/index.ts"; + +type Hooks = Awaited>>; +type ChatHook = NonNullable; +type ToolHook = NonNullable; +type EventHook = NonNullable; +type SystemHook = NonNullable; + +let trackedHooks: Hooks[] = []; + +async function cleanupTrackedHooks(): Promise { + const hooksToCleanup = trackedHooks; + trackedHooks = []; + await Promise.allSettled(hooksToCleanup.map((hooks) => hooks.destroy?.() ?? Promise.resolve())); +} + +async function getHooks(db: string, projectId = "test-project"): Promise { + const plugin = createObsxaPlugin({ db, projectId }); + const hooks = await plugin({ project: { id: projectId }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); + return hooks; +} + +function chatInput( + sessionID: string, + messageID?: string, + agent?: string, + model?: unknown, +): Parameters[0] { + return { sessionID, messageID, agent, model }; +} + +function chatOutput(message: unknown, parts: unknown[]): Parameters[1] { + return { message, parts }; +} + +function textPart(text: string): { type: string; text: string } { + return { type: "text", text }; +} + +function toolInput( + tool: string, + sessionID: string, + callID: string, + args: unknown, +): Parameters[0] { + return { tool, sessionID, callID, args }; +} + +function toolOutput(title: string, output: string, metadata: unknown): Parameters[1] { + return { title, output, metadata }; +} + +function eventInput(type: string, properties: unknown): Parameters[0] { + return { event: { type, properties } }; +} + +function systemInput(sessionID?: string): Parameters[0] { + return { sessionID, model: null }; +} + +describe("createObsxaPlugin", () => { + it("is a function", () => { + expect(typeof createObsxaPlugin).toBe("function"); + }); + + it("returns a Plugin (async function)", async () => { + const plugin = createObsxaPlugin({ db: ":memory:" }); + expect(typeof plugin).toBe("function"); + }); + + it("plugin factory returns a Hooks object", async () => { + const plugin = createObsxaPlugin({ db: ":memory:" }); + const hooks = await plugin({ project: { id: "test" }, directory: "/tmp", worktree: "/tmp" }); + expect(typeof hooks).toBe("object"); + expect(hooks).not.toBeNull(); + }); +}); + +describe("createObsxaPlugin factory", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates ObsxaInstance with accessible project store", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "test-project" }); + const hooks = await plugin({ + project: { id: "test-project" }, + directory: "/tmp", + worktree: "/tmp", + }); + + expect(hooks).toBeDefined(); + expect(typeof hooks).toBe("object"); + }); + + it("ensures project exists after factory call", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "test-project" }); + await plugin({ project: { id: "test-project" }, directory: "/tmp", worktree: "/tmp" }); + + const obsxa = createObsxa({ db: dbPath }); + const project = obsxa.project.get("test-project"); + obsxa.close(); + + expect(project).not.toBeNull(); + expect(project?.id).toBe("test-project"); + }); + + it("is idempotent (no error on second call with same projectId)", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "my-project" }); + + await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); + await expect( + plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }), + ).resolves.toBeDefined(); + }); + + it("returns hooks object with expected keys", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); + const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + + expect(hooks).toHaveProperty("chat.message"); + expect(hooks).toHaveProperty("tool.execute.after"); + expect(hooks).toHaveProperty("event"); + expect(hooks).toHaveProperty("experimental.chat.system.transform"); + }); + + it("hook functions are callable async functions", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); + const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + + expect(typeof hooks.destroy).toBe("function"); + expect(typeof hooks["chat.message"]).toBe("function"); + expect(typeof hooks["tool.execute.after"]).toBe("function"); + expect(typeof hooks["event"]).toBe("function"); + expect(typeof hooks["experimental.chat.system.transform"]).toBe("function"); + }); + + it("destroy closes plugin resources without throwing", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); + const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + + await expect(hooks.destroy?.()).resolves.toBeUndefined(); + await expect(hooks.destroy?.()).resolves.toBeUndefined(); + }); + + it("uses input.project.id as default projectId when not provided in options", async () => { + const plugin = createObsxaPlugin({ db: dbPath }); + await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + + const obsxa = createObsxa({ db: dbPath }); + const project = obsxa.project.get("from-input"); + obsxa.close(); + + expect(project?.id).toBe("from-input"); + }); + + it("uses projectId from options when provided", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "explicit-id" }); + await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + + const obsxa = createObsxa({ db: dbPath }); + const project = obsxa.project.get("explicit-id"); + obsxa.close(); + + expect(project?.id).toBe("explicit-id"); + }); + + it("uses projectName from options when provided", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1", projectName: "My Project" }); + await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + + const obsxa = createObsxa({ db: dbPath }); + const project = obsxa.project.get("p1"); + obsxa.close(); + + expect(project?.name).toBe("My Project"); + }); + + it("defaults projectName to projectId when not provided", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); + await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + + const obsxa = createObsxa({ db: dbPath }); + const project = obsxa.project.get("p1"); + obsxa.close(); + + expect(project?.name).toBe("p1"); + }); +}); + +describe("chat.message hook", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-msg-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates observation from user message", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Found interesting pattern in temperature data")]), + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].collector).toBe("opencode:chat.message"); + expect(obs[0].type).toBe("pattern"); + expect(obs[0].sourceType).toBe("manual"); + }); + + it("sets correct sourceRef with sessionID and messageID", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("sess-42", "msg-7"), + chatOutput({}, [textPart("Analyzing Bitcoin key patterns in dataset")]), + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs[0].sourceRef).toBe("session:sess-42:message:msg-7"); + }); + + it("dedup: same content bumps frequency instead of creating new", async () => { + const hooks = await getHooks(dbPath); + const msg = chatOutput({}, [textPart("Found interesting pattern in sensor data analysis")]); + await hooks["chat.message"]!(chatInput("s1", "m1"), msg); + await hooks["chat.message"]!(chatInput("s1", "m2"), msg); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); + + it("different content creates separate observations", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Found interesting pattern in sensor data analysis")]), + ); + await hooks["chat.message"]!( + chatInput("s1", "m2"), + chatOutput({}, [textPart("Detected anomaly in token distribution over time")]), + ); + + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + + expect(obs).toHaveLength(2); + expect(obs.every((item) => item.frequency === 1)).toBe(true); + }); + + it("inputHash is SHA-256 hex (64 chars)", async () => { + const hooks = await getHooks(dbPath, "p1"); + await hooks["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Temperature anomaly detected in dataset analysis")]), + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("p1"); + obsxa.close(); + expect(obs[0].inputHash).toBeTruthy(); + expect(obs[0].inputHash).toHaveLength(64); + }); + + it("skips messages shorter than 20 chars", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!(chatInput("s1", "m1"), chatOutput({}, [textPart("short")])); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(0); + }); + + it("does NOT throw on malformed input (empty parts)", async () => { + const hooks = await getHooks(dbPath); + await expect( + hooks["chat.message"]!(chatInput("s1", undefined), chatOutput(null, [])), + ).resolves.toBeUndefined(); + }); + + it("does NOT throw on completely null output", async () => { + const hooks = await getHooks(dbPath); + const nullOutput = null as unknown as Parameters[1]; + await expect(hooks["chat.message"]!(chatInput("s1"), nullOutput)).resolves.toBeUndefined(); + }); + + it("context contains sessionID and agent metadata", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("s-ctx", "m-ctx", "claude", { providerID: "anthropic", modelID: "claude-3" }), + chatOutput({}, [textPart("Context metadata test in temperature analysis")]), + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs[0].context).toBeTruthy(); + const ctx = JSON.parse(obs[0].context as string); + expect(ctx.sessionID).toBe("s-ctx"); + expect(ctx.agent).toBe("claude"); + }); +}); + +describe("tool.execute.after hook", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-tool-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates measurement observation for write tool", async () => { + const hooks = await getHooks(dbPath); + await hooks["tool.execute.after"]!( + { tool: "write", sessionID: "s1", callID: "c1", args: { filePath: "/test.ts" } }, + { title: "Wrote test.ts", output: "File created", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].collector).toBe("opencode:tool.execute.after"); + expect(obs[0].type).toBe("measurement"); + expect(obs[0].sourceType).toBe("computation"); + }); + + it("skips read-only tool: read", async () => { + const hooks = await getHooks(dbPath); + await hooks["tool.execute.after"]!( + { tool: "read", sessionID: "s1", callID: "c1", args: {} }, + { title: "Read file", output: "content", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(0); + }); + + it("skips read-only tool: grep", async () => { + const hooks = await getHooks(dbPath); + await hooks["tool.execute.after"]!( + { tool: "grep", sessionID: "s1", callID: "c1", args: {} }, + { title: "Grep result", output: "matches", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(0); + }); + + it("skips read-only tool: glob", async () => { + const hooks = await getHooks(dbPath); + await hooks["tool.execute.after"]!( + { tool: "glob", sessionID: "s1", callID: "c1", args: {} }, + { title: "Glob result", output: "files", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(0); + }); + + it("dedup: same tool call bumps frequency", async () => { + const hooks = await getHooks(dbPath); + const call = { title: "Wrote index.ts", output: "done", metadata: {} }; + await hooks["tool.execute.after"]!( + { tool: "write", sessionID: "s1", callID: "c1", args: {} }, + call, + ); + await hooks["tool.execute.after"]!( + { tool: "write", sessionID: "s1", callID: "c2", args: {} }, + call, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); + + it("sets correct sourceRef with sessionID and callID", async () => { + const hooks = await getHooks(dbPath); + await hooks["tool.execute.after"]!( + { tool: "bash", sessionID: "sess-99", callID: "call-7", args: {} }, + { title: "Executed bash command", output: "ok", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs[0].sourceRef).toBe("session:sess-99:call:call-7"); + }); + + it("does NOT throw on malformed input", async () => { + const hooks = await getHooks(dbPath); + const malformedInput = { + tool: "write", + sessionID: null, + callID: null, + args: null, + } as unknown as Parameters[0]; + const malformedOutput = null as unknown as Parameters[1]; + await expect( + hooks["tool.execute.after"]!(malformedInput, malformedOutput), + ).resolves.toBeUndefined(); + }); + + it("creates derived_from relation to message observation when same session", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("shared-session", "m1"), + chatOutput({}, [textPart("Analyzing security patterns in codebase")]), + ); + await hooks["tool.execute.after"]!( + { tool: "bash", sessionID: "shared-session", callID: "c1", args: {} }, + { title: "Ran security scan command", output: "found issues", metadata: {} }, + ); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + expect(obs).toHaveLength(2); + const toolObs = obs.find((o) => o.collector === "opencode:tool.execute.after")!; + const relations = obsxa.relation.list(toolObs.id); + obsxa.close(); + expect(relations.some((r) => r.type === "derived_from")).toBe(true); + }); +}); + +describe("event hook", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-evt-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("file.edited creates artifact observation", async () => { + const hooks = await getHooks(dbPath); + await hooks.event!(eventInput("file.edited", { file: "src/index.ts" })); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("artifact"); + expect(obs[0].collector).toBe("opencode:event:file.edited"); + expect(obs[0].title).toContain("src/index.ts"); + }); + + it("session.created creates pattern observation", async () => { + const hooks = await getHooks(dbPath); + await hooks.event!(eventInput("session.created", { info: { id: "sess-1" } })); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("pattern"); + expect(obs[0].collector).toBe("opencode:event:session.created"); + }); + + it("command.executed creates correlation observation", async () => { + const hooks = await getHooks(dbPath); + await hooks.event!(eventInput("command.executed", { name: "git-commit", sessionID: "s1" })); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("correlation"); + expect(obs[0].title).toContain("git-commit"); + }); + + it("irrelevant events (pty.created) are skipped", async () => { + const hooks = await getHooks(dbPath); + await hooks.event!(eventInput("pty.created", {})); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(0); + }); + + it("dedup: same file edited multiple times bumps frequency", async () => { + const hooks = await getHooks(dbPath); + const evt = eventInput("file.edited", { file: "src/main.ts" }); + await hooks.event!(evt); + await hooks.event!(evt); + const obsxa = createObsxa({ db: dbPath }); + const obs = obsxa.observation.list("test-project"); + obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); + + it("does NOT throw on unknown event type", async () => { + const hooks = await getHooks(dbPath); + await expect( + hooks.event!(eventInput("completely.unknown.event.type", {})), + ).resolves.toBeUndefined(); + }); +}); + +describe("system.transform hook", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-sys-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("always injects agent instruction even with empty DB", async () => { + const hooks = await getHooks(dbPath); + 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); + }); + + it("instruction mentions observation types", async () => { + const hooks = await getHooks(dbPath); + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + const joined = output.system.join(" "); + expect(joined).toContain("pattern"); + expect(joined).toContain("anomaly"); + expect(joined).toContain("measurement"); + }); + + it("instruction is concise (< 500 chars)", async () => { + 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"))!; + expect(instructionEntry.length).toBeLessThan(500); + }); + + it("injects observations when DB has results", async () => { + const hooks = await getHooks(dbPath); + await hooks["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Bitcoin key generation weakness found in dataset")]), + ); + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + expect(output.system.some((s) => s.includes("Bitcoin"))).toBe(true); + }); + + it("does not inject empty observation section when no results", async () => { + const hooks = await getHooks(dbPath); + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + const hasObsContext = output.system.some((s) => s.includes("## Recent Observations")); + expect(hasObsContext).toBe(false); + }); + + it("cross-project: observations from another project are visible", async () => { + const hooksA = await getHooks(dbPath, "project-a"); + await hooksA["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Bitcoin cryptographic key pattern discovered in analysis")]), + ); + + const hooksB = await getHooks(dbPath, "project-b"); + await hooksB["chat.message"]!( + chatInput("s2", "m2"), + chatOutput({}, [textPart("Bitcoin key analysis query trigger")]), + ); + const output = { system: [] as string[] }; + await hooksB["experimental.chat.system.transform"]!(systemInput(), output); + const joined = output.system.join(" "); + expect(joined).toContain("Bitcoin"); + }); + + it("respects maxInjectedObservations option", async () => { + const hooks = await getHooks(dbPath, "p1"); + for (let i = 0; i < 15; i++) { + await hooks["chat.message"]!( + chatInput("s1", `m${i}`), + chatOutput({}, [textPart(`Observation number ${i} about test analysis patterns`)]), + ); + } + const plugin2 = createObsxaPlugin({ db: dbPath, projectId: "p1", maxInjectedObservations: 3 }); + const hooks2 = await plugin2({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + await hooks2["chat.message"]!( + chatInput("s2", "m99"), + chatOutput({}, [textPart("Observation number 7 about test analysis patterns")]), + ); + const output = { system: [] as string[] }; + await hooks2["experimental.chat.system.transform"]!(systemInput(), output); + const obsSection = output.system.find((s) => s.includes("## Recent Observations")); + if (obsSection) { + const count = obsSection + .split("\n") + .map((line) => line.trim()) + .filter((line) => + /^- \[(pattern|anomaly|measurement|correlation|artifact)\]\s+/.test(line), + ).length; + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThanOrEqual(3); + } + }); + + it("respects maxInjectedChars option", async () => { + const hooks = await getHooks(dbPath, "p1"); + for (let i = 0; i < 5; i++) { + await hooks["chat.message"]!( + chatInput("s1", `m${i}`), + chatOutput({}, [ + textPart( + `Very long observation about temperature analysis patterns ${i} with detailed description text`, + ), + ]), + ); + } + const maxInjectedChars = 200; + const formattingBuffer = 100; + const plugin2 = createObsxaPlugin({ db: dbPath, projectId: "p1", maxInjectedChars }); + const hooks2 = await plugin2({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + await hooks2["chat.message"]!( + chatInput("s2", "m99"), + chatOutput({}, [textPart("Temperature analysis patterns observation test run")]), + ); + const output = { system: [] as string[] }; + await hooks2["experimental.chat.system.transform"]!(systemInput(), output); + const obsSection = output.system.find( + (s) => s.includes("Recent Observations") || s.includes("obsxa-context"), + ); + if (obsSection) { + expect(obsSection.length).toBeLessThanOrEqual(maxInjectedChars + formattingBuffer); + } + }); + + it("falls back to project name when message buffer is empty", async () => { + const hooks = await getHooks(dbPath, "my-project"); + const output = { system: [] as string[] }; + await expect( + hooks["experimental.chat.system.transform"]!(systemInput(), output), + ).resolves.toBeUndefined(); + expect(output.system.some((s) => s.includes("observation tool"))).toBe(true); + }); + + it("does NOT throw when FTS search fails (simulated by empty query)", async () => { + const hooks = await getHooks(dbPath); + const output = { system: [] as string[] }; + await expect( + hooks["experimental.chat.system.transform"]!(systemInput(), output), + ).resolves.toBeUndefined(); + }); + + it("does NOT throw on null output", async () => { + const hooks = await getHooks(dbPath); + const nullOutput = null as unknown as Parameters[1]; + await expect( + hooks["experimental.chat.system.transform"]!(systemInput(), nullOutput), + ).resolves.toBeUndefined(); + }); +}); + +describe("full lifecycle integration", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-plugin-int-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(async () => { + await cleanupTrackedHooks(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("records observations across full session lifecycle", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "integration-test" }); + const hooks = await plugin({ + project: { id: "integration-test" }, + directory: "/tmp", + worktree: "/tmp", + }); + + await hooks.event!(eventInput("session.created", { info: { id: "s1" } })); + + await hooks["chat.message"]!( + chatInput("s1", "m1"), + chatOutput({}, [textPart("Analyzing Bitcoin key patterns")]), + ); + + await hooks["tool.execute.after"]!( + toolInput("bash", "s1", "c1", { command: "analyze" }), + toolOutput("bash result", "Found weak RNG pattern", {}), + ); + + await hooks.event!(eventInput("file.edited", { file: "analysis.ts" })); + + await hooks["chat.message"]!( + chatInput("s1", "m2"), + chatOutput({}, [textPart("Analyzing Bitcoin key patterns")]), + ); + + const output = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]!(systemInput(), output); + + const obsxa = createObsxa({ db: dbPath }); + const observations = obsxa.observation.list("integration-test"); + + expect(observations).toHaveLength(4); + + const messageObservations = observations.filter((o) => o.collector === "opencode:chat.message"); + expect(messageObservations).toHaveLength(1); + const messageObs = messageObservations[0]; + expect(messageObs.frequency).toBe(2); + + const toolObs = observations.find((o) => o.collector === "opencode:tool.execute.after"); + expect(toolObs).toBeDefined(); + const relations = obsxa.relation.list(toolObs!.id); + expect( + relations.some( + (r) => + r.type === "derived_from" && + r.fromObservationId === toolObs!.id && + r.toObservationId === messageObs.id, + ), + ).toBe(true); + + const injected = output.system.join(" "); + expect(output.system.length).toBeGreaterThan(0); + expect(output.system.some((s) => s.includes("observation tool"))).toBe(true); + expect( + injected.includes("Bitcoin") || injected.includes("key patterns") || injected.includes("RNG"), + ).toBe(true); + + obsxa.close(); + }); +}); From 7bad87d9620dd1dc9f18a5fc0f1e28281f7be922 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:23:37 +0100 Subject: [PATCH 02/19] fix: guard tool hook against null output --- src/opencode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/opencode.ts b/src/opencode.ts index 1b06cce..a65433f 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -231,6 +231,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { "tool.execute.after": async (toolInput, toolOutput) => { try { if (closed) return; + if (!toolOutput) return; if (SKIP_TOOLS.has(toolInput.tool)) return; const title = (toolOutput.title || toolInput.tool).slice(0, 200); From cd4bbe6d35888a57324b6d83f3c45310869a615d Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:27:36 +0100 Subject: [PATCH 03/19] fix: guard event hook against missing payload --- src/opencode.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/opencode.ts b/src/opencode.ts index a65433f..b6a6b7b 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -293,6 +293,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { event: async (evtInput) => { try { if (closed) return; + if (!evtInput?.event) return; const evt = evtInput.event as { type: string; properties: Record }; const obsType = TRACKED_EVENTS[evt.type]; if (!obsType) return; From 34c9ab5b98862c67365732c12556f4c6eadb50bd Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:35:12 +0100 Subject: [PATCH 04/19] test: tighten plugin and db-path edge cases --- test/db-path.test.ts | 6 ++++++ test/opencode.test.ts | 19 +++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/test/db-path.test.ts b/test/db-path.test.ts index fde07f4..f6171b0 100644 --- a/test/db-path.test.ts +++ b/test/db-path.test.ts @@ -61,4 +61,10 @@ describe("getDefaultDbPath", () => { "Home directory must not be empty", ); }); + + it("throws when home directory is whitespace only", () => { + expect(() => getDefaultDbPath({} as NodeJS.ProcessEnv, " ", "linux")).toThrow( + "Home directory must not be empty", + ); + }); }); diff --git a/test/opencode.test.ts b/test/opencode.test.ts index af4229d..4b350ba 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -626,16 +626,15 @@ describe("system.transform hook", () => { const output = { system: [] as string[] }; await hooks2["experimental.chat.system.transform"]!(systemInput(), output); const obsSection = output.system.find((s) => s.includes("## Recent Observations")); - if (obsSection) { - const count = obsSection - .split("\n") - .map((line) => line.trim()) - .filter((line) => - /^- \[(pattern|anomaly|measurement|correlation|artifact)\]\s+/.test(line), - ).length; - expect(count).toBeGreaterThan(0); - expect(count).toBeLessThanOrEqual(3); - } + expect(obsSection).toBeTruthy(); + const count = obsSection! + .split("\n") + .map((line) => line.trim()) + .filter((line) => + /^- \[(pattern|anomaly|measurement|correlation|artifact)\]\s+/.test(line), + ).length; + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThanOrEqual(3); }); it("respects maxInjectedChars option", async () => { From 16b0b98d9690228041969cfdf36add73d295747c Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:39:55 +0100 Subject: [PATCH 05/19] fix: handle session idle events explicitly --- src/opencode.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/opencode.ts b/src/opencode.ts index b6a6b7b..dcaf6f3 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -318,6 +318,11 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const sessionId = typeof info?.id === "string" ? info.id : "unknown"; title = `Session created: ${sessionId}`; source = sessionId; + } else if (evt.type === "session.idle") { + const sessionId = typeof props.sessionID === "string" ? props.sessionID : "unknown"; + const idleMs = typeof props.idleMs === "number" ? props.idleMs : undefined; + title = idleMs !== undefined ? `Session idle: ${sessionId} (${idleMs}ms)` : `Session idle: ${sessionId}`; + source = sessionId; } else { title = `Event: ${evt.type}`; source = evt.type; From 4d7ca6dfce91ee75f23490ee088288ce65d1060c Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:44:57 +0100 Subject: [PATCH 06/19] test: track plugin hooks for teardown cleanup --- test/opencode.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/opencode.test.ts b/test/opencode.test.ts index 4b350ba..c3a8578 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -619,6 +619,7 @@ describe("system.transform hook", () => { } const plugin2 = createObsxaPlugin({ db: dbPath, projectId: "p1", maxInjectedObservations: 3 }); const hooks2 = await plugin2({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks2); await hooks2["chat.message"]!( chatInput("s2", "m99"), chatOutput({}, [textPart("Observation number 7 about test analysis patterns")]), @@ -653,6 +654,7 @@ describe("system.transform hook", () => { const formattingBuffer = 100; const plugin2 = createObsxaPlugin({ db: dbPath, projectId: "p1", maxInjectedChars }); const hooks2 = await plugin2({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks2); await hooks2["chat.message"]!( chatInput("s2", "m99"), chatOutput({}, [textPart("Temperature analysis patterns observation test run")]), @@ -714,6 +716,7 @@ describe("full lifecycle integration", () => { directory: "/tmp", worktree: "/tmp", }); + trackedHooks.push(hooks); await hooks.event!(eventInput("session.created", { info: { id: "s1" } })); From 48f24d00ca6cd2f3d0ad270c23f5ec11cc26349f Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:50:48 +0100 Subject: [PATCH 07/19] test: align observation cap check with FTS results --- test/opencode.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/opencode.test.ts b/test/opencode.test.ts index c3a8578..611908a 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -626,6 +626,11 @@ describe("system.transform hook", () => { ); const output = { system: [] as string[] }; await hooks2["experimental.chat.system.transform"]!(systemInput(), output); + + const obsxa = createObsxa({ db: dbPath }); + const expectedCount = obsxa.search.search("Observation number 7 about test analysis patterns", undefined, 3).length; + obsxa.close(); + const obsSection = output.system.find((s) => s.includes("## Recent Observations")); expect(obsSection).toBeTruthy(); const count = obsSection! @@ -634,8 +639,9 @@ describe("system.transform hook", () => { .filter((line) => /^- \[(pattern|anomaly|measurement|correlation|artifact)\]\s+/.test(line), ).length; - expect(count).toBeGreaterThan(0); - expect(count).toBeLessThanOrEqual(3); + expect(expectedCount).toBeGreaterThan(0); + expect(expectedCount).toBeLessThanOrEqual(3); + expect(count).toBe(expectedCount); }); it("respects maxInjectedChars option", async () => { @@ -651,7 +657,7 @@ describe("system.transform hook", () => { ); } const maxInjectedChars = 200; - const formattingBuffer = 100; + const wrapperOverhead = `\n\n`.length; const plugin2 = createObsxaPlugin({ db: dbPath, projectId: "p1", maxInjectedChars }); const hooks2 = await plugin2({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); trackedHooks.push(hooks2); @@ -665,7 +671,7 @@ describe("system.transform hook", () => { (s) => s.includes("Recent Observations") || s.includes("obsxa-context"), ); if (obsSection) { - expect(obsSection.length).toBeLessThanOrEqual(maxInjectedChars + formattingBuffer); + expect(obsSection.length).toBeLessThanOrEqual(maxInjectedChars + wrapperOverhead); } }); From afe611f4d5f81577e770322f80e2cadf75545f9c Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:49:59 +0100 Subject: [PATCH 08/19] fix: align opencode plugin with async obsxa API --- src/opencode.ts | 42 +++++------ test/opencode.test.ts | 162 +++++++++++++++++++++--------------------- 2 files changed, 103 insertions(+), 101 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index dcaf6f3..cf51557 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -94,15 +94,15 @@ function computeInputHash(payload: string, collector: string, projectId: string) .digest("hex"); } -function findByHash( +async function findByHash( obsxa: ObsxaInstance, projectId: string, hash: string, cache: Map, -): number | undefined { +): Promise { const cached = getCacheValue(cache, hash); if (cached !== undefined) return cached; - const found = obsxa.observation.getByInputHash(projectId, hash); + const found = await obsxa.observation.getByInputHash(projectId, hash); if (!found) return undefined; setCacheValue(cache, hash, found.id, MAX_HASH_CACHE_SIZE); return found.id; @@ -138,14 +138,14 @@ function logHookError(scope: string, err: unknown): void { export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { return async (input: PluginInput): Promise => { const db = options?.db ?? getDefaultDbPath(); - const obsxa = createObsxa({ db }); + const obsxa = await createObsxa({ db }); let closed = false; const projectId = options?.projectId ?? input.project.id; const projectName = options?.projectName ?? projectId; - if (!obsxa.project.get(projectId)) { - obsxa.project.add({ id: projectId, name: projectName }); + if (!(await obsxa.project.get(projectId))) { + await obsxa.project.add({ id: projectId, name: projectName }); } let latestMessageBuffer = ""; @@ -161,7 +161,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { sessionMessageObs.clear(); latestMessageBuffer = ""; try { - obsxa.close(); + await obsxa.close(); } catch (err) { logHookError("destroy", err); } @@ -194,14 +194,14 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const collector = "opencode:chat.message"; const hash = computeInputHash(text, collector, projectId); - const existingId = findByHash(obsxa, projectId, hash, hashCache); + const existingId = await findByHash(obsxa, projectId, hash, hashCache); if (existingId !== undefined) { - obsxa.observation.incrementFrequency(existingId); + await obsxa.observation.incrementFrequency(existingId); return; } const sourceRef = `session:${msgInput.sessionID ?? "unknown"}:message:${msgInput.messageID ?? "unknown"}`; - const obs = obsxa.observation.add({ + const obs = await obsxa.observation.add({ projectId, title, description: text.length > 200 ? text.slice(0, 500) : undefined, @@ -239,16 +239,16 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const dedupPayload = `${toolOutput.title || toolInput.tool}\n${String(toolOutput.output ?? "")}`; const hash = computeInputHash(dedupPayload, collector, projectId); - const existingId = findByHash(obsxa, projectId, hash, hashCache); + const existingId = await findByHash(obsxa, projectId, hash, hashCache); if (existingId !== undefined) { - obsxa.observation.incrementFrequency(existingId); + await obsxa.observation.incrementFrequency(existingId); return; } const sourceRef = `session:${toolInput.sessionID}:call:${toolInput.callID}`; const description = toolOutput.output ? toolOutput.output.slice(0, 500) : undefined; - const obs = obsxa.observation.add({ + const obs = await obsxa.observation.add({ projectId, title, description, @@ -271,10 +271,10 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const msgObsId = getCacheValue(sessionMessageObs, toolInput.sessionID); if (msgObsId !== undefined) { try { - obsxa.relation.add({ - fromObservationId: obs.id, - toObservationId: msgObsId, - type: "derived_from", + await obsxa.relation.add({ + fromObservationId: obs.id, + toObservationId: msgObsId, + type: "derived_from", }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -336,13 +336,13 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { projectId, ); - const existingId = findByHash(obsxa, projectId, hash, hashCache); + const existingId = await findByHash(obsxa, projectId, hash, hashCache); if (existingId !== undefined) { - obsxa.observation.incrementFrequency(existingId); + await obsxa.observation.incrementFrequency(existingId); return; } - const obs = obsxa.observation.add({ + const obs = await obsxa.observation.add({ projectId, title, type: obsType, @@ -373,7 +373,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const query = latestMessageBuffer || projectName; // Cross-project search: pass undefined projectId - const results = obsxa.search.search(query, undefined, maxObs); + const results = await obsxa.search.search(query, undefined, maxObs); if (results.length > 0) { const formatted = formatObservations(results, maxChars); diff --git a/test/opencode.test.ts b/test/opencode.test.ts index 611908a..c5bf9cd 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -112,9 +112,9 @@ describe("createObsxaPlugin factory", () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "test-project" }); await plugin({ project: { id: "test-project" }, directory: "/tmp", worktree: "/tmp" }); - const obsxa = createObsxa({ db: dbPath }); - const project = obsxa.project.get("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("test-project"); + await obsxa.close(); expect(project).not.toBeNull(); expect(project?.id).toBe("test-project"); @@ -162,9 +162,9 @@ describe("createObsxaPlugin factory", () => { const plugin = createObsxaPlugin({ db: dbPath }); await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); - const obsxa = createObsxa({ db: dbPath }); - const project = obsxa.project.get("from-input"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("from-input"); + await obsxa.close(); expect(project?.id).toBe("from-input"); }); @@ -173,9 +173,9 @@ describe("createObsxaPlugin factory", () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "explicit-id" }); await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); - const obsxa = createObsxa({ db: dbPath }); - const project = obsxa.project.get("explicit-id"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("explicit-id"); + await obsxa.close(); expect(project?.id).toBe("explicit-id"); }); @@ -184,9 +184,9 @@ describe("createObsxaPlugin factory", () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1", projectName: "My Project" }); await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); - const obsxa = createObsxa({ db: dbPath }); - const project = obsxa.project.get("p1"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("p1"); + await obsxa.close(); expect(project?.name).toBe("My Project"); }); @@ -195,9 +195,9 @@ describe("createObsxaPlugin factory", () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); - const obsxa = createObsxa({ db: dbPath }); - const project = obsxa.project.get("p1"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("p1"); + await obsxa.close(); expect(project?.name).toBe("p1"); }); @@ -223,9 +223,9 @@ describe("chat.message hook", () => { chatInput("s1", "m1"), chatOutput({}, [textPart("Found interesting pattern in temperature data")]), ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].collector).toBe("opencode:chat.message"); expect(obs[0].type).toBe("pattern"); @@ -238,9 +238,9 @@ describe("chat.message hook", () => { chatInput("sess-42", "msg-7"), chatOutput({}, [textPart("Analyzing Bitcoin key patterns in dataset")]), ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs[0].sourceRef).toBe("session:sess-42:message:msg-7"); }); @@ -249,9 +249,9 @@ describe("chat.message hook", () => { const msg = chatOutput({}, [textPart("Found interesting pattern in sensor data analysis")]); await hooks["chat.message"]!(chatInput("s1", "m1"), msg); await hooks["chat.message"]!(chatInput("s1", "m2"), msg); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].frequency).toBe(2); }); @@ -267,9 +267,9 @@ describe("chat.message hook", () => { chatOutput({}, [textPart("Detected anomaly in token distribution over time")]), ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(2); expect(obs.every((item) => item.frequency === 1)).toBe(true); @@ -281,9 +281,9 @@ describe("chat.message hook", () => { chatInput("s1", "m1"), chatOutput({}, [textPart("Temperature anomaly detected in dataset analysis")]), ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("p1"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("p1"); + await obsxa.close(); expect(obs[0].inputHash).toBeTruthy(); expect(obs[0].inputHash).toHaveLength(64); }); @@ -291,9 +291,9 @@ describe("chat.message hook", () => { it("skips messages shorter than 20 chars", async () => { const hooks = await getHooks(dbPath); await hooks["chat.message"]!(chatInput("s1", "m1"), chatOutput({}, [textPart("short")])); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(0); }); @@ -316,9 +316,9 @@ describe("chat.message hook", () => { chatInput("s-ctx", "m-ctx", "claude", { providerID: "anthropic", modelID: "claude-3" }), chatOutput({}, [textPart("Context metadata test in temperature analysis")]), ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs[0].context).toBeTruthy(); const ctx = JSON.parse(obs[0].context as string); expect(ctx.sessionID).toBe("s-ctx"); @@ -346,9 +346,9 @@ describe("tool.execute.after hook", () => { { tool: "write", sessionID: "s1", callID: "c1", args: { filePath: "/test.ts" } }, { title: "Wrote test.ts", output: "File created", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].collector).toBe("opencode:tool.execute.after"); expect(obs[0].type).toBe("measurement"); @@ -361,9 +361,9 @@ describe("tool.execute.after hook", () => { { tool: "read", sessionID: "s1", callID: "c1", args: {} }, { title: "Read file", output: "content", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(0); }); @@ -373,9 +373,9 @@ describe("tool.execute.after hook", () => { { tool: "grep", sessionID: "s1", callID: "c1", args: {} }, { title: "Grep result", output: "matches", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(0); }); @@ -385,9 +385,9 @@ describe("tool.execute.after hook", () => { { tool: "glob", sessionID: "s1", callID: "c1", args: {} }, { title: "Glob result", output: "files", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(0); }); @@ -402,9 +402,9 @@ describe("tool.execute.after hook", () => { { tool: "write", sessionID: "s1", callID: "c2", args: {} }, call, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].frequency).toBe(2); }); @@ -415,9 +415,9 @@ describe("tool.execute.after hook", () => { { tool: "bash", sessionID: "sess-99", callID: "call-7", args: {} }, { title: "Executed bash command", output: "ok", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs[0].sourceRef).toBe("session:sess-99:call:call-7"); }); @@ -445,12 +445,12 @@ describe("tool.execute.after hook", () => { { tool: "bash", sessionID: "shared-session", callID: "c1", args: {} }, { title: "Ran security scan command", output: "found issues", metadata: {} }, ); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); expect(obs).toHaveLength(2); const toolObs = obs.find((o) => o.collector === "opencode:tool.execute.after")!; - const relations = obsxa.relation.list(toolObs.id); - obsxa.close(); + const relations = await obsxa.relation.list(toolObs.id); + await obsxa.close(); expect(relations.some((r) => r.type === "derived_from")).toBe(true); }); }); @@ -472,9 +472,9 @@ describe("event hook", () => { it("file.edited creates artifact observation", async () => { const hooks = await getHooks(dbPath); await hooks.event!(eventInput("file.edited", { file: "src/index.ts" })); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].type).toBe("artifact"); expect(obs[0].collector).toBe("opencode:event:file.edited"); @@ -484,9 +484,9 @@ describe("event hook", () => { it("session.created creates pattern observation", async () => { const hooks = await getHooks(dbPath); await hooks.event!(eventInput("session.created", { info: { id: "sess-1" } })); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].type).toBe("pattern"); expect(obs[0].collector).toBe("opencode:event:session.created"); @@ -495,9 +495,9 @@ describe("event hook", () => { it("command.executed creates correlation observation", async () => { const hooks = await getHooks(dbPath); await hooks.event!(eventInput("command.executed", { name: "git-commit", sessionID: "s1" })); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].type).toBe("correlation"); expect(obs[0].title).toContain("git-commit"); @@ -506,9 +506,9 @@ describe("event hook", () => { it("irrelevant events (pty.created) are skipped", async () => { const hooks = await getHooks(dbPath); await hooks.event!(eventInput("pty.created", {})); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(0); }); @@ -517,9 +517,9 @@ describe("event hook", () => { const evt = eventInput("file.edited", { file: "src/main.ts" }); await hooks.event!(evt); await hooks.event!(evt); - const obsxa = createObsxa({ db: dbPath }); - const obs = obsxa.observation.list("test-project"); - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); expect(obs).toHaveLength(1); expect(obs[0].frequency).toBe(2); }); @@ -627,9 +627,11 @@ describe("system.transform hook", () => { const output = { system: [] as string[] }; await hooks2["experimental.chat.system.transform"]!(systemInput(), output); - const obsxa = createObsxa({ db: dbPath }); - const expectedCount = obsxa.search.search("Observation number 7 about test analysis patterns", undefined, 3).length; - obsxa.close(); + const obsxa = await createObsxa({ db: dbPath }); + const expectedCount = ( + await obsxa.search.search("Observation number 7 about test analysis patterns", undefined, 3) + ).length; + await obsxa.close(); const obsSection = output.system.find((s) => s.includes("## Recent Observations")); expect(obsSection).toBeTruthy(); @@ -746,8 +748,8 @@ describe("full lifecycle integration", () => { const output = { system: [] as string[] }; await hooks["experimental.chat.system.transform"]!(systemInput(), output); - const obsxa = createObsxa({ db: dbPath }); - const observations = obsxa.observation.list("integration-test"); + const obsxa = await createObsxa({ db: dbPath }); + const observations = await obsxa.observation.list("integration-test"); expect(observations).toHaveLength(4); @@ -758,7 +760,7 @@ describe("full lifecycle integration", () => { const toolObs = observations.find((o) => o.collector === "opencode:tool.execute.after"); expect(toolObs).toBeDefined(); - const relations = obsxa.relation.list(toolObs!.id); + const relations = await obsxa.relation.list(toolObs!.id); expect( relations.some( (r) => @@ -775,6 +777,6 @@ describe("full lifecycle integration", () => { injected.includes("Bitcoin") || injected.includes("key patterns") || injected.includes("RNG"), ).toBe(true); - obsxa.close(); + await obsxa.close(); }); }); From 978c9418dc97dbd085415b41d82e8282cbebfe06 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:58:14 +0100 Subject: [PATCH 09/19] fix: harden plugin session handling and db path validation --- src/ai.ts | 7 +++ src/core/db-path.ts | 35 ++++++++++----- src/opencode.ts | 99 +++++++++++++++++++++++++++++++++---------- test/opencode.test.ts | 32 ++++++++++---- 4 files changed, 131 insertions(+), 42 deletions(-) diff --git a/src/ai.ts b/src/ai.ts index 5409402..6f26076 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,3 +1,4 @@ +import { isAbsolute } from "node:path"; import { tool } from "ai"; import { z } from "zod/v4"; import { getDefaultDbPath } from "./core/db-path.ts"; @@ -6,6 +7,12 @@ import type { ObsxaInstance } from "./index.ts"; function sanitizeDbPath(path?: string): string { const dbPath = path ?? getDefaultDbPath(); + if ( + path && + (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) + ) { + throw new Error("Database path must be relative"); + } if (dbPath.includes("..")) throw new Error("Database path must not contain '..'"); if (!dbPath.endsWith(".db")) throw new Error("Database path must end with '.db'"); return dbPath; diff --git a/src/core/db-path.ts b/src/core/db-path.ts index a062e8a..a52f702 100644 --- a/src/core/db-path.ts +++ b/src/core/db-path.ts @@ -1,25 +1,38 @@ import { homedir } from "node:os"; -import { join, win32 } from "node:path"; +import { posix, win32 } from "node:path"; export function getDefaultDbPath( env: NodeJS.ProcessEnv = process.env, home = homedir(), platform: NodeJS.Platform = process.platform, ): string { + const xdgDataHome = env.XDG_DATA_HOME?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + + if (xdgDataHome && xdgDataHome.length > 0) { + return platform === "win32" + ? win32.join(xdgDataHome, "obsxa", "obsxa.db") + : posix.join(xdgDataHome, "obsxa", "obsxa.db"); + } + + if (platform === "win32") { + if (localAppData && localAppData.length > 0) { + return win32.join(localAppData, "obsxa", "obsxa.db"); + } + if (!home || home.trim().length === 0) { + throw new Error("Home directory must not be empty"); + } + return win32.join(home, "AppData", "Local", "obsxa", "obsxa.db"); + } + if (!home || home.trim().length === 0) { throw new Error("Home directory must not be empty"); } - const xdgDataHome = env.XDG_DATA_HOME?.trim(); const fallbackDataHome = - platform === "win32" - ? env.LOCALAPPDATA?.trim() || win32.join(home, "AppData", "Local") - : platform === "darwin" - ? join(home, "Library", "Application Support") - : join(home, ".local", "share"); + platform === "darwin" + ? posix.join(home, "Library", "Application Support") + : posix.join(home, ".local", "share"); - const dataHome = xdgDataHome && xdgDataHome.length > 0 ? xdgDataHome : fallbackDataHome; - return platform === "win32" - ? win32.join(dataHome, "obsxa", "obsxa.db") - : join(dataHome, "obsxa", "obsxa.db"); + return posix.join(fallbackDataHome, "obsxa", "obsxa.db"); } diff --git a/src/opencode.ts b/src/opencode.ts index cf51557..3d5aa47 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -61,7 +61,7 @@ const TRACKED_EVENTS: Record, key: string): number | undefined { +function getCacheValue(cache: Map, key: string): T | undefined { const value = cache.get(key); if (value === undefined) return undefined; cache.delete(key); @@ -69,10 +69,10 @@ function getCacheValue(cache: Map, key: string): number | undefi return value; } -function setCacheValue( - cache: Map, +function setCacheValue( + cache: Map, key: string, - value: number, + value: T, maxSize: number, ): void { if (cache.has(key)) { @@ -135,31 +135,75 @@ function logHookError(scope: string, err: unknown): void { console.warn(`[obsxa] ${scope} hook error`, err); } +function isSqliteConstraintError(error: unknown): boolean { + let current: unknown = error; + while (current) { + const obj = current as { + message?: unknown; + code?: unknown; + rawCode?: unknown; + extendedCode?: unknown; + cause?: unknown; + }; + const message = typeof obj.message === "string" ? obj.message : String(obj.message ?? ""); + const code = typeof obj.code === "string" ? obj.code : String(obj.code ?? ""); + const rawCode = String(obj.rawCode ?? ""); + const extendedCode = + typeof obj.extendedCode === "string" ? obj.extendedCode : String(obj.extendedCode ?? ""); + if ( + message.includes("UNIQUE constraint") || + message.includes("SQLITE_CONSTRAINT") || + code.includes("SQLITE_CONSTRAINT") || + extendedCode.includes("SQLITE_CONSTRAINT") || + rawCode === "1555" + ) { + return true; + } + current = obj.cause; + } + return false; +} + export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { return async (input: PluginInput): Promise => { const db = options?.db ?? getDefaultDbPath(); const obsxa = await createObsxa({ db }); let closed = false; - const projectId = options?.projectId ?? input.project.id; - const projectName = options?.projectName ?? projectId; + const closeOnInitError = async () => { + if (closed) return; + closed = true; + try { + await obsxa.close(); + } catch (err) { + logHookError("init.close", err); + } + }; + + try { + const projectId = options?.projectId ?? input.project.id; + const projectName = options?.projectName ?? projectId; + let latestMessageBuffer = ""; - if (!(await obsxa.project.get(projectId))) { - await obsxa.project.add({ id: projectId, name: projectName }); - } + try { + await obsxa.project.add({ id: projectId, name: projectName }); + } catch (error) { + if (!isSqliteConstraintError(error)) { + throw error; + } + } - let latestMessageBuffer = ""; - const hashCache = new Map(); - // sessionID -> message observation ID (for derived_from relations) - const sessionMessageObs = new Map(); + const latestMessageBufferBySession = new Map(); + const hashCache = new Map(); + const sessionMessageObs = new Map(); - return { - destroy: async () => { + return { + destroy: async () => { if (closed) return; closed = true; hashCache.clear(); sessionMessageObs.clear(); - latestMessageBuffer = ""; + latestMessageBufferBySession.clear(); try { await obsxa.close(); } catch (err) { @@ -185,9 +229,12 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { .trim(); if (text.length < 20) return; - latestMessageBuffer = text; + if (msgInput.sessionID) { + setCacheValue(latestMessageBufferBySession, msgInput.sessionID, text, MAX_SESSION_CACHE_SIZE); + } + const msgObj = output.message as { summary?: { title?: string } } | null | undefined; const title = (msgObj?.summary?.title ?? text.slice(0, 100)).slice(0, 200); @@ -197,6 +244,9 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const existingId = await findByHash(obsxa, projectId, hash, hashCache); if (existingId !== undefined) { await obsxa.observation.incrementFrequency(existingId); + if (msgInput.sessionID) { + setCacheValue(sessionMessageObs, msgInput.sessionID, existingId, MAX_SESSION_CACHE_SIZE); + } return; } @@ -369,10 +419,11 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { // Always push agent instruction sysOutput.system.push(`\n${AGENT_INSTRUCTION}\n`); - // Use buffered message text or fall back to project name - const query = latestMessageBuffer || projectName; + const query = + (_sysInput.sessionID + ? latestMessageBufferBySession.get(_sysInput.sessionID) + : latestMessageBuffer) ?? projectName; - // Cross-project search: pass undefined projectId const results = await obsxa.search.search(query, undefined, maxObs); if (results.length > 0) { @@ -382,8 +433,12 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { } catch (err) { logHookError("system.transform", err); } - }, - }; + }, + }; + } catch (error) { + await closeOnInitError(); + throw error; + } }; } diff --git a/test/opencode.test.ts b/test/opencode.test.ts index c5bf9cd..88bc7a5 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -65,6 +65,10 @@ function systemInput(sessionID?: string): Parameters[0] { } describe("createObsxaPlugin", () => { + afterEach(async () => { + await cleanupTrackedHooks(); + }); + it("is a function", () => { expect(typeof createObsxaPlugin).toBe("function"); }); @@ -77,6 +81,7 @@ describe("createObsxaPlugin", () => { it("plugin factory returns a Hooks object", async () => { const plugin = createObsxaPlugin({ db: ":memory:" }); const hooks = await plugin({ project: { id: "test" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); expect(typeof hooks).toBe("object"); expect(hooks).not.toBeNull(); }); @@ -103,6 +108,7 @@ describe("createObsxaPlugin factory", () => { directory: "/tmp", worktree: "/tmp", }); + trackedHooks.push(hooks); expect(hooks).toBeDefined(); expect(typeof hooks).toBe("object"); @@ -110,7 +116,8 @@ describe("createObsxaPlugin factory", () => { it("ensures project exists after factory call", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "test-project" }); - await plugin({ project: { id: "test-project" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ project: { id: "test-project" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); const project = await obsxa.project.get("test-project"); @@ -123,15 +130,16 @@ describe("createObsxaPlugin factory", () => { it("is idempotent (no error on second call with same projectId)", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "my-project" }); - await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); - await expect( - plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }), - ).resolves.toBeDefined(); + const hooks1 = await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); + const hooks2 = await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks1, hooks2); + expect(hooks2).toBeDefined(); }); it("returns hooks object with expected keys", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); expect(hooks).toHaveProperty("chat.message"); expect(hooks).toHaveProperty("tool.execute.after"); @@ -142,6 +150,7 @@ describe("createObsxaPlugin factory", () => { it("hook functions are callable async functions", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); expect(typeof hooks.destroy).toBe("function"); expect(typeof hooks["chat.message"]).toBe("function"); @@ -153,6 +162,7 @@ describe("createObsxaPlugin factory", () => { it("destroy closes plugin resources without throwing", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); await expect(hooks.destroy?.()).resolves.toBeUndefined(); await expect(hooks.destroy?.()).resolves.toBeUndefined(); @@ -160,7 +170,8 @@ describe("createObsxaPlugin factory", () => { it("uses input.project.id as default projectId when not provided in options", async () => { const plugin = createObsxaPlugin({ db: dbPath }); - await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); const project = await obsxa.project.get("from-input"); @@ -171,7 +182,8 @@ describe("createObsxaPlugin factory", () => { it("uses projectId from options when provided", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "explicit-id" }); - await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); const project = await obsxa.project.get("explicit-id"); @@ -182,7 +194,8 @@ describe("createObsxaPlugin factory", () => { it("uses projectName from options when provided", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1", projectName: "My Project" }); - await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); const project = await obsxa.project.get("p1"); @@ -193,7 +206,8 @@ describe("createObsxaPlugin factory", () => { it("defaults projectName to projectId when not provided", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); - await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ project: { id: "p1" }, directory: "/tmp", worktree: "/tmp" }); + trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); const project = await obsxa.project.get("p1"); From c497d9556662f75fa1d7512ec7cb1457e4444690 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:00:21 +0100 Subject: [PATCH 10/19] fix: create local db directory before opening sqlite file --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 56adc36..956fe6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,6 +224,9 @@ export async function createObsxa(options: ObsxaOptions = {}): Promise Date: Wed, 11 Mar 2026 20:07:15 +0100 Subject: [PATCH 11/19] chore: refresh pnpm lockfile --- pnpm-lock.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d65da83..5baf7da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: ^0.44.0 version: 0.44.7(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) devDependencies: + '@opencode-ai/plugin': + specifier: latest + version: 1.2.24 '@types/node': specifier: ^25.3.0 version: 25.4.0 @@ -637,6 +640,12 @@ packages: '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@opencode-ai/plugin@1.2.24': + resolution: {integrity: sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw==} + + '@opencode-ai/sdk@1.2.24': + resolution: {integrity: sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -2036,6 +2045,9 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2416,6 +2428,13 @@ snapshots: '@neon-rs/load@0.0.4': {} + '@opencode-ai/plugin@1.2.24': + dependencies: + '@opencode-ai/sdk': 1.2.24 + zod: 4.1.8 + + '@opencode-ai/sdk@1.2.24': {} + '@opentelemetry/api@1.9.0': {} '@oxc-project/types@0.115.0': {} @@ -3633,4 +3652,6 @@ snapshots: dependencies: is-wsl: 3.1.1 + zod@4.1.8: {} + zod@4.3.6: {} From a3e35cda1e2a8033a32224e2d6f7d65d71e34481 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:08:09 +0000 Subject: [PATCH 12/19] chore: apply automated updates --- CHANGELOG.md | 1 + src/ai.ts | 5 +- src/opencode.ts | 438 +++++++++++++++++++++--------------------- test/opencode.test.ts | 30 ++- 4 files changed, 251 insertions(+), 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 931739c..fbe782c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### ⚠️ Notes - **db path:** `createObsxa()` now defaults to an XDG-compliant data path (`~/.local/share/obsxa/obsxa.db` on Linux) instead of `./obsxa.db`. Pass `db: "./obsxa.db"` explicitly to keep legacy location. + ## v0.0.2 ### 🏡 Chore diff --git a/src/ai.ts b/src/ai.ts index 6f26076..13aa06c 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -7,10 +7,7 @@ import type { ObsxaInstance } from "./index.ts"; function sanitizeDbPath(path?: string): string { const dbPath = path ?? getDefaultDbPath(); - if ( - path && - (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) - ) { + if (path && (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"))) { throw new Error("Database path must be relative"); } if (dbPath.includes("..")) throw new Error("Database path must not contain '..'"); diff --git a/src/opencode.ts b/src/opencode.ts index 3d5aa47..494d79d 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -69,12 +69,7 @@ function getCacheValue(cache: Map, key: string): T | undefined { return value; } -function setCacheValue( - cache: Map, - key: string, - value: T, - maxSize: number, -): void { +function setCacheValue(cache: Map, key: string, value: T, maxSize: number): void { if (cache.has(key)) { cache.delete(key); } @@ -199,240 +194,255 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { return { destroy: async () => { - if (closed) return; - closed = true; - hashCache.clear(); - sessionMessageObs.clear(); - latestMessageBufferBySession.clear(); - try { - await obsxa.close(); - } catch (err) { - logHookError("destroy", err); - } - }, - - "chat.message": async (msgInput, msgOutput) => { - try { if (closed) return; - const output = msgOutput as { message: unknown; parts: unknown[] } | null; - if (!output) return; - - const parts = (output.parts ?? []) as Array<{ - type: string; - text?: string; - content?: string; - }>; - const text = parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? p.content ?? "") - .join(" ") - .trim(); - - if (text.length < 20) return; - latestMessageBuffer = text; - - if (msgInput.sessionID) { - setCacheValue(latestMessageBufferBySession, msgInput.sessionID, text, MAX_SESSION_CACHE_SIZE); + closed = true; + hashCache.clear(); + sessionMessageObs.clear(); + latestMessageBufferBySession.clear(); + try { + await obsxa.close(); + } catch (err) { + logHookError("destroy", err); } + }, - const msgObj = output.message as { summary?: { title?: string } } | null | undefined; - const title = (msgObj?.summary?.title ?? text.slice(0, 100)).slice(0, 200); - - const collector = "opencode:chat.message"; - const hash = computeInputHash(text, collector, projectId); + "chat.message": async (msgInput, msgOutput) => { + try { + if (closed) return; + const output = msgOutput as { message: unknown; parts: unknown[] } | null; + if (!output) return; + + const parts = (output.parts ?? []) as Array<{ + type: string; + text?: string; + content?: string; + }>; + const text = parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? p.content ?? "") + .join(" ") + .trim(); + + if (text.length < 20) return; + latestMessageBuffer = text; - const existingId = await findByHash(obsxa, projectId, hash, hashCache); - if (existingId !== undefined) { - await obsxa.observation.incrementFrequency(existingId); if (msgInput.sessionID) { - setCacheValue(sessionMessageObs, msgInput.sessionID, existingId, MAX_SESSION_CACHE_SIZE); + setCacheValue( + latestMessageBufferBySession, + msgInput.sessionID, + text, + MAX_SESSION_CACHE_SIZE, + ); } - return; - } - const sourceRef = `session:${msgInput.sessionID ?? "unknown"}:message:${msgInput.messageID ?? "unknown"}`; - const obs = await obsxa.observation.add({ - projectId, - title, - description: text.length > 200 ? text.slice(0, 500) : undefined, - type: "pattern", - source: msgInput.agent ?? "user", - sourceType: "manual", - collector, - sourceRef, - inputHash: hash, - context: JSON.stringify({ - sessionID: msgInput.sessionID, - agent: msgInput.agent, - model: msgInput.model, - messageID: msgInput.messageID, - }), - }); - - setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); - if (msgInput.sessionID) { - setCacheValue(sessionMessageObs, msgInput.sessionID, obs.id, MAX_SESSION_CACHE_SIZE); - } - } catch (err) { - logHookError("chat.message", err); - } - }, + const msgObj = output.message as { summary?: { title?: string } } | null | undefined; + const title = (msgObj?.summary?.title ?? text.slice(0, 100)).slice(0, 200); + + const collector = "opencode:chat.message"; + const hash = computeInputHash(text, collector, projectId); + + const existingId = await findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + if (msgInput.sessionID) { + setCacheValue( + sessionMessageObs, + msgInput.sessionID, + existingId, + MAX_SESSION_CACHE_SIZE, + ); + } + return; + } - "tool.execute.after": async (toolInput, toolOutput) => { - try { - if (closed) return; - if (!toolOutput) return; - if (SKIP_TOOLS.has(toolInput.tool)) return; - - const title = (toolOutput.title || toolInput.tool).slice(0, 200); - const collector = "opencode:tool.execute.after"; - const dedupPayload = `${toolOutput.title || toolInput.tool}\n${String(toolOutput.output ?? "")}`; - const hash = computeInputHash(dedupPayload, collector, projectId); - - const existingId = await findByHash(obsxa, projectId, hash, hashCache); - if (existingId !== undefined) { - await obsxa.observation.incrementFrequency(existingId); - return; + const sourceRef = `session:${msgInput.sessionID ?? "unknown"}:message:${msgInput.messageID ?? "unknown"}`; + const obs = await obsxa.observation.add({ + projectId, + title, + description: text.length > 200 ? text.slice(0, 500) : undefined, + type: "pattern", + source: msgInput.agent ?? "user", + sourceType: "manual", + collector, + sourceRef, + inputHash: hash, + context: JSON.stringify({ + sessionID: msgInput.sessionID, + agent: msgInput.agent, + model: msgInput.model, + messageID: msgInput.messageID, + }), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + if (msgInput.sessionID) { + setCacheValue(sessionMessageObs, msgInput.sessionID, obs.id, MAX_SESSION_CACHE_SIZE); + } + } catch (err) { + logHookError("chat.message", err); } + }, + + "tool.execute.after": async (toolInput, toolOutput) => { + try { + if (closed) return; + if (!toolOutput) return; + if (SKIP_TOOLS.has(toolInput.tool)) return; + + const title = (toolOutput.title || toolInput.tool).slice(0, 200); + const collector = "opencode:tool.execute.after"; + const dedupPayload = `${toolOutput.title || toolInput.tool}\n${String(toolOutput.output ?? "")}`; + const hash = computeInputHash(dedupPayload, collector, projectId); + + const existingId = await findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + return; + } - const sourceRef = `session:${toolInput.sessionID}:call:${toolInput.callID}`; - const description = toolOutput.output ? toolOutput.output.slice(0, 500) : undefined; - - const obs = await obsxa.observation.add({ - projectId, - title, - description, - type: "measurement", - source: toolInput.tool, - sourceType: "computation", - collector, - sourceRef, - inputHash: hash, - context: JSON.stringify({ - tool: toolInput.tool, - callID: toolInput.callID, - sessionID: toolInput.sessionID, - }), - }); - - setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); - - // Create derived_from relation to message observation if exists for this session - const msgObsId = getCacheValue(sessionMessageObs, toolInput.sessionID); - if (msgObsId !== undefined) { - try { + const sourceRef = `session:${toolInput.sessionID}:call:${toolInput.callID}`; + const description = toolOutput.output ? toolOutput.output.slice(0, 500) : undefined; + + const obs = await obsxa.observation.add({ + projectId, + title, + description, + type: "measurement", + source: toolInput.tool, + sourceType: "computation", + collector, + sourceRef, + inputHash: hash, + context: JSON.stringify({ + tool: toolInput.tool, + callID: toolInput.callID, + sessionID: toolInput.sessionID, + }), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + + // Create derived_from relation to message observation if exists for this session + const msgObsId = getCacheValue(sessionMessageObs, toolInput.sessionID); + if (msgObsId !== undefined) { + try { await obsxa.relation.add({ fromObservationId: obs.id, toObservationId: msgObsId, type: "derived_from", - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const isConstraintViolation = - message.includes("UNIQUE constraint") || message.includes("SQLITE_CONSTRAINT"); - if (!isConstraintViolation) { - logHookError("tool.execute.after.relation", err); + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const isConstraintViolation = + message.includes("UNIQUE constraint") || message.includes("SQLITE_CONSTRAINT"); + if (!isConstraintViolation) { + logHookError("tool.execute.after.relation", err); + } } } + } catch (err) { + logHookError("tool.execute.after", err); } - } catch (err) { - logHookError("tool.execute.after", err); - } - }, + }, - event: async (evtInput) => { - try { - if (closed) return; - if (!evtInput?.event) return; - const evt = evtInput.event as { type: string; properties: Record }; - const obsType = TRACKED_EVENTS[evt.type]; - if (!obsType) return; - - const props = evt.properties ?? {}; - - let title: string; - let source: string; - if (evt.type === "file.edited") { - const file = (props.file as string) ?? "unknown"; - title = `File edited: ${file}`; - source = file; - } else if (evt.type === "command.executed") { - const name = (props.name as string) ?? "unknown"; - title = `Command executed: ${name}`; - source = name; - } else if (evt.type === "session.created") { - const info = - typeof props.info === "object" && props.info !== null - ? (props.info as Record) - : null; - const sessionId = typeof info?.id === "string" ? info.id : "unknown"; - title = `Session created: ${sessionId}`; - source = sessionId; - } else if (evt.type === "session.idle") { - const sessionId = typeof props.sessionID === "string" ? props.sessionID : "unknown"; - const idleMs = typeof props.idleMs === "number" ? props.idleMs : undefined; - title = idleMs !== undefined ? `Session idle: ${sessionId} (${idleMs}ms)` : `Session idle: ${sessionId}`; - source = sessionId; - } else { - title = `Event: ${evt.type}`; - source = evt.type; - } + event: async (evtInput) => { + try { + if (closed) return; + if (!evtInput?.event) return; + const evt = evtInput.event as { type: string; properties: Record }; + const obsType = TRACKED_EVENTS[evt.type]; + if (!obsType) return; + + const props = evt.properties ?? {}; + + let title: string; + let source: string; + if (evt.type === "file.edited") { + const file = (props.file as string) ?? "unknown"; + title = `File edited: ${file}`; + source = file; + } else if (evt.type === "command.executed") { + const name = (props.name as string) ?? "unknown"; + title = `Command executed: ${name}`; + source = name; + } else if (evt.type === "session.created") { + const info = + typeof props.info === "object" && props.info !== null + ? (props.info as Record) + : null; + const sessionId = typeof info?.id === "string" ? info.id : "unknown"; + title = `Session created: ${sessionId}`; + source = sessionId; + } else if (evt.type === "session.idle") { + const sessionId = typeof props.sessionID === "string" ? props.sessionID : "unknown"; + const idleMs = typeof props.idleMs === "number" ? props.idleMs : undefined; + title = + idleMs !== undefined + ? `Session idle: ${sessionId} (${idleMs}ms)` + : `Session idle: ${sessionId}`; + source = sessionId; + } else { + title = `Event: ${evt.type}`; + source = evt.type; + } - title = title.slice(0, 200); - const collector = `opencode:event:${evt.type}`; - const hash = computeInputHash( - `${evt.type}:${JSON.stringify(props)}`, - collector, - projectId, - ); - - const existingId = await findByHash(obsxa, projectId, hash, hashCache); - if (existingId !== undefined) { - await obsxa.observation.incrementFrequency(existingId); - return; - } + title = title.slice(0, 200); + const collector = `opencode:event:${evt.type}`; + const hash = computeInputHash( + `${evt.type}:${JSON.stringify(props)}`, + collector, + projectId, + ); + + const existingId = await findByHash(obsxa, projectId, hash, hashCache); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + return; + } - const obs = await obsxa.observation.add({ - projectId, - title, - type: obsType, - source, - sourceType: "external", - collector, - inputHash: hash, - context: JSON.stringify(props), - }); - - setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); - } catch (err) { - logHookError("event", err); - } - }, + const obs = await obsxa.observation.add({ + projectId, + title, + type: obsType, + source, + sourceType: "external", + collector, + inputHash: hash, + context: JSON.stringify(props), + }); + + setCacheValue(hashCache, hash, obs.id, MAX_HASH_CACHE_SIZE); + } catch (err) { + logHookError("event", err); + } + }, - "experimental.chat.system.transform": async (_sysInput, sysOutput) => { - try { - if (closed) return; - if (!sysOutput || !Array.isArray(sysOutput.system)) return; - const maxObs = options?.maxInjectedObservations ?? 10; - const maxChars = options?.maxInjectedChars ?? 2000; + "experimental.chat.system.transform": async (_sysInput, sysOutput) => { + try { + if (closed) return; + if (!sysOutput || !Array.isArray(sysOutput.system)) return; + const maxObs = options?.maxInjectedObservations ?? 10; + const maxChars = options?.maxInjectedChars ?? 2000; - // Always push agent instruction - sysOutput.system.push(`\n${AGENT_INSTRUCTION}\n`); + // Always push agent instruction + sysOutput.system.push( + `\n${AGENT_INSTRUCTION}\n`, + ); - const query = - (_sysInput.sessionID - ? latestMessageBufferBySession.get(_sysInput.sessionID) - : latestMessageBuffer) ?? projectName; + const query = + (_sysInput.sessionID + ? latestMessageBufferBySession.get(_sysInput.sessionID) + : latestMessageBuffer) ?? projectName; - const results = await obsxa.search.search(query, undefined, maxObs); + const results = await obsxa.search.search(query, undefined, maxObs); - if (results.length > 0) { - const formatted = formatObservations(results, maxChars); - sysOutput.system.push(`\n${formatted}\n`); + if (results.length > 0) { + const formatted = formatObservations(results, maxChars); + sysOutput.system.push(`\n${formatted}\n`); + } + } catch (err) { + logHookError("system.transform", err); } - } catch (err) { - logHookError("system.transform", err); - } }, }; } catch (error) { diff --git a/test/opencode.test.ts b/test/opencode.test.ts index 88bc7a5..033f7ea 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -116,7 +116,11 @@ describe("createObsxaPlugin factory", () => { it("ensures project exists after factory call", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "test-project" }); - const hooks = await plugin({ project: { id: "test-project" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ + project: { id: "test-project" }, + directory: "/tmp", + worktree: "/tmp", + }); trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); @@ -130,8 +134,16 @@ describe("createObsxaPlugin factory", () => { it("is idempotent (no error on second call with same projectId)", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "my-project" }); - const hooks1 = await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); - const hooks2 = await plugin({ project: { id: "my-project" }, directory: "/tmp", worktree: "/tmp" }); + const hooks1 = await plugin({ + project: { id: "my-project" }, + directory: "/tmp", + worktree: "/tmp", + }); + const hooks2 = await plugin({ + project: { id: "my-project" }, + directory: "/tmp", + worktree: "/tmp", + }); trackedHooks.push(hooks1, hooks2); expect(hooks2).toBeDefined(); }); @@ -170,7 +182,11 @@ describe("createObsxaPlugin factory", () => { it("uses input.project.id as default projectId when not provided in options", async () => { const plugin = createObsxaPlugin({ db: dbPath }); - const hooks = await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ + project: { id: "from-input" }, + directory: "/tmp", + worktree: "/tmp", + }); trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); @@ -182,7 +198,11 @@ describe("createObsxaPlugin factory", () => { it("uses projectId from options when provided", async () => { const plugin = createObsxaPlugin({ db: dbPath, projectId: "explicit-id" }); - const hooks = await plugin({ project: { id: "from-input" }, directory: "/tmp", worktree: "/tmp" }); + const hooks = await plugin({ + project: { id: "from-input" }, + directory: "/tmp", + worktree: "/tmp", + }); trackedHooks.push(hooks); const obsxa = await createObsxa({ db: dbPath }); From 87c962983433da18ea1550df2c504e48a1de08a4 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:12:39 +0100 Subject: [PATCH 13/19] fix: harden obsxa context injection and project-scoped recall --- src/core/db-path.ts | 12 ++++++++---- src/opencode.ts | 31 +++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/core/db-path.ts b/src/core/db-path.ts index a52f702..8f42f2d 100644 --- a/src/core/db-path.ts +++ b/src/core/db-path.ts @@ -1,6 +1,10 @@ import { homedir } from "node:os"; import { posix, win32 } from "node:path"; +function isAbsoluteForPlatform(path: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path); +} + export function getDefaultDbPath( env: NodeJS.ProcessEnv = process.env, home = homedir(), @@ -9,23 +13,23 @@ export function getDefaultDbPath( const xdgDataHome = env.XDG_DATA_HOME?.trim(); const localAppData = env.LOCALAPPDATA?.trim(); - if (xdgDataHome && xdgDataHome.length > 0) { + if (xdgDataHome && xdgDataHome.length > 0 && isAbsoluteForPlatform(xdgDataHome, platform)) { return platform === "win32" ? win32.join(xdgDataHome, "obsxa", "obsxa.db") : posix.join(xdgDataHome, "obsxa", "obsxa.db"); } if (platform === "win32") { - if (localAppData && localAppData.length > 0) { + if (localAppData && localAppData.length > 0 && win32.isAbsolute(localAppData)) { return win32.join(localAppData, "obsxa", "obsxa.db"); } - if (!home || home.trim().length === 0) { + if (!home || home.trim().length === 0 || !win32.isAbsolute(home)) { throw new Error("Home directory must not be empty"); } return win32.join(home, "AppData", "Local", "obsxa", "obsxa.db"); } - if (!home || home.trim().length === 0) { + if (!home || home.trim().length === 0 || !posix.isAbsolute(home)) { throw new Error("Home directory must not be empty"); } diff --git a/src/opencode.ts b/src/opencode.ts index 494d79d..669969c 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -112,9 +112,17 @@ function formatObservations( }>, maxChars: number, ): string { + const sanitizeForContext = (value: string): string => + value + .replace(/\r?\n+/g, " ") + .replace(/&/g, "&") + .replace(//g, ">") + .trim(); + const lines = results.map( (r) => - `- [${r.observation.type}] ${r.observation.title} (${r.observation.confidence}%, seen ${r.observation.frequency}x)`, + `- [${sanitizeForContext(r.observation.type)}] ${sanitizeForContext(r.observation.title)} (${r.observation.confidence}%, seen ${r.observation.frequency}x)`, ); let output = "## Recent Observations\n" + lines.join("\n"); if (output.length > maxChars) { @@ -297,6 +305,20 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { const existingId = await findByHash(obsxa, projectId, hash, hashCache); if (existingId !== undefined) { await obsxa.observation.incrementFrequency(existingId); + const msgObsId = getCacheValue(sessionMessageObs, toolInput.sessionID); + if (msgObsId !== undefined) { + try { + await obsxa.relation.add({ + fromObservationId: existingId, + toObservationId: msgObsId, + type: "derived_from", + }); + } catch (err) { + if (!isSqliteConstraintError(err)) { + logHookError("tool.execute.after.relation", err); + } + } + } return; } @@ -332,10 +354,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { type: "derived_from", }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const isConstraintViolation = - message.includes("UNIQUE constraint") || message.includes("SQLITE_CONSTRAINT"); - if (!isConstraintViolation) { + if (!isSqliteConstraintError(err)) { logHookError("tool.execute.after.relation", err); } } @@ -434,7 +453,7 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { ? latestMessageBufferBySession.get(_sysInput.sessionID) : latestMessageBuffer) ?? projectName; - const results = await obsxa.search.search(query, undefined, maxObs); + const results = await obsxa.search.search(query, projectId, maxObs); if (results.length > 0) { const formatted = formatObservations(results, maxChars); From 19c5282a015495cfd6c230d156074cf9cbee4505 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:23:34 +0000 Subject: [PATCH 14/19] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- src/ai.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ai.ts b/src/ai.ts index 13aa06c..749a91f 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -7,6 +7,9 @@ import type { ObsxaInstance } from "./index.ts"; function sanitizeDbPath(path?: string): string { const dbPath = path ?? getDefaultDbPath(); + if (path && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(path)) { + throw new Error("Database path must be relative"); + } if (path && (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"))) { throw new Error("Database path must be relative"); } @@ -479,4 +482,4 @@ export const dedupTool = tool({ await obsxa.close(); } }, -}); +}); \ No newline at end of file From c6be4f0e19287b4dfb4bcad3329eaf8ee891a0e6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:24:05 +0000 Subject: [PATCH 15/19] chore: apply automated updates --- src/ai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai.ts b/src/ai.ts index 749a91f..d7ace5e 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -482,4 +482,4 @@ export const dedupTool = tool({ await obsxa.close(); } }, -}); \ No newline at end of file +}); From 4771e88c352624079983b5a210adf44ad1cbafb0 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:50:25 +0100 Subject: [PATCH 16/19] fix: align opencode hook types and harden event/context handling --- src/opencode.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 8 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index 669969c..977a482 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -11,30 +11,94 @@ export interface ObsxaPluginOptions { maxInjectedChars?: number; } -// Inline OpenCode plugin types (no runtime dependency on @opencode-ai/plugin) -// Source: /tmp/opencode/packages/plugin/src/index.ts type PluginInput = { + client: unknown; project: { id: string; [key: string]: unknown }; directory: string; worktree: string; - [key: string]: unknown; + serverUrl: URL; + $: unknown; }; type Hooks = { destroy?: () => Promise; + event?: (input: { event: { type: string; properties: Record } }) => Promise; + config?: (input: unknown) => Promise; + tool?: Record; + auth?: unknown; "chat.message"?: ( - input: { sessionID: string; agent?: string; model?: unknown; messageID?: string }, - output: { message: unknown; parts: unknown[] }, + input: { + sessionID: string; + agent?: string; + model?: { providerID: string; modelID: string }; + messageID?: string; + variant?: string; + }, + output: { + message: unknown; + parts: Array<{ type: string; text?: string; content?: string; [key: string]: unknown }>; + }, + ) => Promise; + "chat.params"?: ( + input: { + sessionID: string; + agent: string; + model: unknown; + provider: { source: "env" | "config" | "custom" | "api"; info: unknown; options: Record }; + message: unknown; + }, + output: { temperature: number; topP: number; topK: number; options: Record }, + ) => Promise; + "chat.headers"?: ( + input: { + sessionID: string; + agent: string; + model: unknown; + provider: { source: "env" | "config" | "custom" | "api"; info: unknown; options: Record }; + message: unknown; + }, + output: { headers: Record }, + ) => Promise; + "permission.ask"?: ( + input: unknown, + output: { status: "ask" | "deny" | "allow" }, + ) => Promise; + "command.execute.before"?: ( + input: { command: string; sessionID: string; arguments: string }, + output: { parts: unknown[] }, + ) => Promise; + "tool.execute.before"?: ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: unknown }, + ) => Promise; + "shell.env"?: ( + input: { cwd: string; sessionID?: string; callID?: string }, + output: { env: Record }, ) => Promise; "tool.execute.after"?: ( input: { tool: string; sessionID: string; callID: string; args: unknown }, output: { title: string; output: string; metadata: unknown }, ) => Promise; - event?: (input: { event: { type: string; properties: unknown } }) => Promise; + "experimental.chat.messages.transform"?: ( + input: {}, + output: { messages: Array<{ info: unknown; parts: unknown[] }> }, + ) => Promise; "experimental.chat.system.transform"?: ( input: { sessionID?: string; model: unknown }, output: { system: string[] }, ) => Promise; + "experimental.session.compacting"?: ( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ) => Promise; + "experimental.text.complete"?: ( + input: { sessionID: string; messageID: string; partID: string }, + output: { text: string }, + ) => Promise; + "tool.definition"?: ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; }; export type Plugin = (input: PluginInput) => Promise; @@ -126,6 +190,8 @@ function formatObservations( ); let output = "## Recent Observations\n" + lines.join("\n"); if (output.length > maxChars) { + if (maxChars <= 0) return ""; + if (maxChars <= 3) return ".".repeat(maxChars); const chars = Array.from(output); const truncated = chars.slice(0, Math.max(0, maxChars - 3)).join(""); const lastNewline = truncated.lastIndexOf("\n"); @@ -134,6 +200,33 @@ function formatObservations( return output; } +function sanitizeEventLabel(value: unknown): string { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (typeof value === "object" && value !== null) { + const record = value as Record; + const path = record.path; + if (typeof path === "string") { + const trimmedPath = path.trim(); + if (trimmedPath.length > 0) return trimmedPath; + } + const name = record.name; + if (typeof name === "string") { + const trimmedName = name.trim(); + if (trimmedName.length > 0) return trimmedName; + } + } + + return "unknown"; +} + function logHookError(scope: string, err: unknown): void { console.warn(`[obsxa] ${scope} hook error`, err); } @@ -377,11 +470,11 @@ export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { let title: string; let source: string; if (evt.type === "file.edited") { - const file = (props.file as string) ?? "unknown"; + const file = sanitizeEventLabel(props.file); title = `File edited: ${file}`; source = file; } else if (evt.type === "command.executed") { - const name = (props.name as string) ?? "unknown"; + const name = sanitizeEventLabel(props.name); title = `Command executed: ${name}`; source = name; } else if (evt.type === "session.created") { From 0b1e8dc063af9a64838eb07045635fe14d05a37a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:58:54 +0000 Subject: [PATCH 17/19] chore: apply automated updates --- src/opencode.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index 977a482..99848e6 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -22,7 +22,9 @@ type PluginInput = { type Hooks = { destroy?: () => Promise; - event?: (input: { event: { type: string; properties: Record } }) => Promise; + event?: (input: { + event: { type: string; properties: Record }; + }) => Promise; config?: (input: unknown) => Promise; tool?: Record; auth?: unknown; @@ -44,7 +46,11 @@ type Hooks = { sessionID: string; agent: string; model: unknown; - provider: { source: "env" | "config" | "custom" | "api"; info: unknown; options: Record }; + provider: { + source: "env" | "config" | "custom" | "api"; + info: unknown; + options: Record; + }; message: unknown; }, output: { temperature: number; topP: number; topK: number; options: Record }, @@ -54,7 +60,11 @@ type Hooks = { sessionID: string; agent: string; model: unknown; - provider: { source: "env" | "config" | "custom" | "api"; info: unknown; options: Record }; + provider: { + source: "env" | "config" | "custom" | "api"; + info: unknown; + options: Record; + }; message: unknown; }, output: { headers: Record }, From 5fb1f299a3d166f25084c4de95393be2ba8db13f Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:02:29 +0100 Subject: [PATCH 18/19] fix: keep opencode plugin input backward compatible --- src/opencode.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/opencode.ts b/src/opencode.ts index 99848e6..3217f29 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -12,12 +12,12 @@ export interface ObsxaPluginOptions { } type PluginInput = { - client: unknown; + client?: unknown; project: { id: string; [key: string]: unknown }; directory: string; worktree: string; - serverUrl: URL; - $: unknown; + serverUrl?: URL; + $?: unknown; }; type Hooks = { From 44d0d6ea78b3fef224dc004c9da55015627c3055 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:04:29 +0100 Subject: [PATCH 19/19] chore: pin dev dependency versions --- package.json | 4 ++-- pnpm-lock.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index a01af44..0937f98 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ "drizzle-orm": "^0.44.0" }, "devDependencies": { - "@opencode-ai/plugin": "latest", + "@opencode-ai/plugin": "1.2.24", "@types/node": "^25.3.0", - "@typescript/native-preview": "latest", + "@typescript/native-preview": "7.0.0-dev.20260310.1", "ai": "^6.0.116", "changelogen": "^0.6.2", "drizzle-kit": "^0.31.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5baf7da..65827d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,13 +25,13 @@ importers: version: 0.44.7(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) devDependencies: '@opencode-ai/plugin': - specifier: latest + specifier: 1.2.24 version: 1.2.24 '@types/node': specifier: ^25.3.0 version: 25.4.0 '@typescript/native-preview': - specifier: latest + specifier: 7.0.0-dev.20260310.1 version: 7.0.0-dev.20260310.1 ai: specifier: ^6.0.116