diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e852f2..fbe782c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### ⚠️ 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/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 8b8e15c..0937f98 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,8 +55,9 @@ "drizzle-orm": "^0.44.0" }, "devDependencies": { + "@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", @@ -64,10 +69,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/pnpm-lock.yaml b/pnpm-lock.yaml index d65da83..65827d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,11 +24,14 @@ 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: 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 @@ -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: {} diff --git a/src/ai.ts b/src/ai.ts index d0d8e9d..d7ace5e 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,12 +1,18 @@ 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 (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"); + } 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 eff1939..9da9a67 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 async function open(dbPath: string) { +export async function open(dbPath?: string) { return createObsxa({ db: dbPath }); } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index c837903..014291d 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -2,6 +2,7 @@ import { defineCommand } from "citty"; import { consola } from "consola"; import { dbArgs, output } from "./_db.ts"; import { backupDatabase, restoreDatabase } from "../backup.ts"; +import { getDefaultDbPath } from "../core/db-path.ts"; export default defineCommand({ meta: { name: "backup", description: "Backup or restore obsxa SQLite database files" }, @@ -16,7 +17,7 @@ export default defineCommand({ }, run({ args }) { try { - const result = backupDatabase(args.db, args.out); + const result = backupDatabase(args.db ?? getDefaultDbPath(), args.out); if (args.toon || args.json) return output(result, args.toon); consola.success(`Backup created: ${result.basePath}`); } catch (err) { @@ -41,7 +42,7 @@ export default defineCommand({ }, run({ args }) { try { - const result = restoreDatabase(args.db, args.from); + const result = restoreDatabase(args.db ?? getDefaultDbPath(), args.from); if (args.toon || args.json) return output(result, args.toon); consola.success(`Database restored from: ${result.restoredFrom}`); if (result.preRestoreBackup) { diff --git a/src/core/db-path.ts b/src/core/db-path.ts new file mode 100644 index 0000000..8f42f2d --- /dev/null +++ b/src/core/db-path.ts @@ -0,0 +1,42 @@ +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(), + platform: NodeJS.Platform = process.platform, +): string { + const xdgDataHome = env.XDG_DATA_HOME?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + + 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 && win32.isAbsolute(localAppData)) { + return win32.join(localAppData, "obsxa", "obsxa.db"); + } + 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 || !posix.isAbsolute(home)) { + throw new Error("Home directory must not be empty"); + } + + const fallbackDataHome = + platform === "darwin" + ? posix.join(home, "Library", "Application Support") + : posix.join(home, ".local", "share"); + + return posix.join(fallbackDataHome, "obsxa", "obsxa.db"); +} diff --git a/src/core/observation.ts b/src/core/observation.ts index 644d88e..3636e7e 100644 --- a/src/core/observation.ts +++ b/src/core/observation.ts @@ -130,6 +130,15 @@ export function createObservationStore(db: ObsxaDB) { return row ? toObservation(row) : null; }, + async getByInputHash(projectId: string, inputHash: string): Promise { + const row = await db + .select() + .from(observations) + .where(and(eq(observations.projectId, projectId), eq(observations.inputHash, inputHash))) + .get(); + return row ? toObservation(row) : null; + }, + async list( projectId: string, opts?: { diff --git a/src/index.ts b/src/index.ts index 75d29e0..956fe6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { drizzle } from "drizzle-orm/libsql/node"; import { migrate } from "drizzle-orm/libsql/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"; @@ -52,6 +53,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; @@ -79,7 +83,7 @@ async function ensureMetaTable(client: Client): Promise { } async function getSchemaVersion(client: Client): Promise { - let result; + let result: Awaited>; try { result = await client.execute({ sql: "SELECT value FROM obsxa_meta WHERE key = ?", @@ -146,6 +150,7 @@ const CUSTOM_SQL = [ "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)", @@ -209,17 +214,19 @@ export interface ObsxaInstance { * await obsxa.close() * ``` */ -export async function createObsxa( - options: ObsxaOptions = { db: "./obsxa.db" }, -): Promise { +export async function createObsxa(options: ObsxaOptions = {}): Promise { + const dbPath = options.db ?? getDefaultDbPath(); const resolved = { autoMigrate: options.autoMigrate ?? true, autoBackup: options.autoBackup ?? true, backupDir: options.backupDir, }; - const dbUrl = toLibsqlUrl(options.db); - const backupDbPath = toBackupDbPath(options.db); + const dbUrl = toLibsqlUrl(dbPath); + const backupDbPath = toBackupDbPath(dbPath); + if (backupDbPath) { + mkdirSync(dirname(backupDbPath), { recursive: true }); + } const client = createClient({ url: dbUrl }); try { try { diff --git a/src/opencode.ts b/src/opencode.ts new file mode 100644 index 0000000..3217f29 --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,577 @@ +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; +} + +type PluginInput = { + client?: unknown; + project: { id: string; [key: string]: unknown }; + directory: string; + worktree: string; + 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?: { 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; + "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; + +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): T | 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: T, 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"); +} + +async function findByHash( + obsxa: ObsxaInstance, + projectId: string, + hash: string, + cache: Map, +): Promise { + const cached = getCacheValue(cache, hash); + if (cached !== undefined) return cached; + const found = await 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 sanitizeForContext = (value: string): string => + value + .replace(/\r?\n+/g, " ") + .replace(/&/g, "&") + .replace(//g, ">") + .trim(); + + const lines = results.map( + (r) => + `- [${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) { + 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"); + output = (lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated) + "..."; + } + 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); +} + +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 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 = ""; + + try { + await obsxa.project.add({ id: projectId, name: projectName }); + } catch (error) { + if (!isSqliteConstraintError(error)) { + throw error; + } + } + + const latestMessageBufferBySession = new Map(); + const hashCache = new Map(); + const sessionMessageObs = new Map(); + + 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, + ); + } + + 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; + } + + 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); + 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; + } + + 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) { + if (!isSqliteConstraintError(err)) { + logHookError("tool.execute.after.relation", 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 = sanitizeEventLabel(props.file); + title = `File edited: ${file}`; + source = file; + } else if (evt.type === "command.executed") { + const name = sanitizeEventLabel(props.name); + 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; + } + + 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; + + // Always push agent instruction + sysOutput.system.push( + `\n${AGENT_INSTRUCTION}\n`, + ); + + const query = + (_sysInput.sessionID + ? latestMessageBufferBySession.get(_sysInput.sessionID) + : latestMessageBuffer) ?? projectName; + + const results = await obsxa.search.search(query, projectId, maxObs); + + if (results.length > 0) { + const formatted = formatObservations(results, maxChars); + sysOutput.system.push(`\n${formatted}\n`); + } + } catch (err) { + logHookError("system.transform", err); + } + }, + }; + } catch (error) { + await closeOnInitError(); + throw error; + } + }; +} + +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..f6171b0 --- /dev/null +++ b/test/db-path.test.ts @@ -0,0 +1,70 @@ +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", + ); + }); + + 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 new file mode 100644 index 0000000..033f7ea --- /dev/null +++ b/test/opencode.test.ts @@ -0,0 +1,816 @@ +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", () => { + afterEach(async () => { + await cleanupTrackedHooks(); + }); + + 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" }); + trackedHooks.push(hooks); + 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", + }); + trackedHooks.push(hooks); + + expect(hooks).toBeDefined(); + expect(typeof hooks).toBe("object"); + }); + + 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", + }); + trackedHooks.push(hooks); + + 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"); + }); + + 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", + }); + 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"); + 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" }); + trackedHooks.push(hooks); + + 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" }); + trackedHooks.push(hooks); + + 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 }); + 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"); + await obsxa.close(); + + expect(project?.id).toBe("from-input"); + }); + + 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", + }); + trackedHooks.push(hooks); + + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("explicit-id"); + await 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" }); + 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"); + await obsxa.close(); + + expect(project?.name).toBe("My Project"); + }); + + it("defaults projectName to projectId when not provided", async () => { + const plugin = createObsxaPlugin({ db: dbPath, projectId: "p1" }); + 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"); + await 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 = 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"); + 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 = 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"); + }); + + 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 = 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); + }); + + 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 = 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); + }); + + 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 = 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); + }); + + 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 = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await 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 = 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"); + 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 = 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"); + 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 = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await 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 = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await 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 = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await 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 = 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); + }); + + 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 = 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"); + }); + + 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 = 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 = await obsxa.relation.list(toolObs.id); + await 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 = 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"); + 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 = 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"); + }); + + 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 = 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"); + }); + + it("irrelevant events (pty.created) are skipped", async () => { + const hooks = await getHooks(dbPath); + await hooks.event!(eventInput("pty.created", {})); + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await 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 = 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); + }); + + 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" }); + trackedHooks.push(hooks2); + 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 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(); + const count = obsSection! + .split("\n") + .map((line) => line.trim()) + .filter((line) => + /^- \[(pattern|anomaly|measurement|correlation|artifact)\]\s+/.test(line), + ).length; + expect(expectedCount).toBeGreaterThan(0); + expect(expectedCount).toBeLessThanOrEqual(3); + expect(count).toBe(expectedCount); + }); + + 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 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); + 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 + wrapperOverhead); + } + }); + + 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", + }); + trackedHooks.push(hooks); + + 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 = await createObsxa({ db: dbPath }); + const observations = await 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 = await 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); + + await obsxa.close(); + }); +});