From 8810a59eff47490717be11f925fd327e63c1212b Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 8 Jun 2026 16:58:34 +0200 Subject: [PATCH] feat: simplify event model + extract EventBus to plugin-lib + fix CI #113 - event-bus: BrainStatusEvent reduced to 6 fields (status, statusText, current, total, version, error) - status.ts: StatusOpts simplified, removed scanning/searching/ingesting/progress flags - tui.tsx: handleStatus simplified to 4 branches (error/init/busy/ready), ready always shows 'ready' - four-opencode-brain.ts: removed all flag params from updateStatus calls - toast import fixed (was missing, root cause of stuck scanning) - EventBus extracted to @four-bytes/opencode-plugin-lib v0.3.0 - CI: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 in codeql.yml - v1.7.0 --- .github/workflows/codeql.yml | 2 ++ bun.lock | 8 ++++-- package.json | 4 +-- src/event-bus.ts | 34 ++-------------------- src/four-opencode-brain.ts | 18 ++++++------ src/status.ts | 55 +++++++++++------------------------- src/tui.tsx | 47 ++++++++---------------------- 7 files changed, 47 insertions(+), 121 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2fd3249..e0d7e13 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,6 +10,8 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: security-events: write steps: diff --git a/bun.lock b/bun.lock index 9116923..672de6c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@four-bytes/four-opencode-brain", "dependencies": { - "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.2.0", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.3.0", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", @@ -81,7 +81,7 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@four-bytes/opencode-plugin-lib": ["@four-bytes/opencode-plugin-lib@github:four-bytes/four-opencode-plugin-lib#6b8d915", { "peerDependencies": { "@opencode-ai/plugin": ">=1.16.0" } }, "four-bytes-four-opencode-plugin-lib-6b8d915", "sha512-c5ZtV9ilcu/hoaXbizBELK0/ShWliFPng2UyBS7xbZ0IK4oso26Ysul6hEmNXZY3jDmCce/4kRc2FwPQbPV8xQ=="], + "@four-bytes/opencode-plugin-lib": ["@four-bytes/opencode-plugin-lib@github:four-bytes/four-opencode-plugin-lib#af1d900", { "peerDependencies": { "@opencode-ai/plugin": ">=1.16.0" } }, "four-bytes-four-opencode-plugin-lib-af1d900", "sha512-zssN7Eo524vy3NLzJ/VyqscwBFWn3t5ri3abxHOTT29x4bKfTqjJpx7EtHNDeoJ7l3oV+hhCLIY8LZq1Lbdv7A=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], @@ -469,7 +469,7 @@ "tree-sitter-typescript": ["tree-sitter-typescript@0.23.2", "", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "tree-sitter-javascript": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -515,6 +515,8 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "bun-ffi-structs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index 000b9e7..8c05ae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", - "version": "1.6.2", + "version": "1.7.0", "description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base", "license": "Apache-2.0", "type": "module", @@ -14,7 +14,7 @@ "four-bytes" ], "dependencies": { - "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.2.0", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.3.0", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", diff --git a/src/event-bus.ts b/src/event-bus.ts index e43e0db..928ff42 100644 --- a/src/event-bus.ts +++ b/src/event-bus.ts @@ -1,40 +1,12 @@ -type Listener = (payload: T) => void; +import { EventBus } from "@four-bytes/opencode-plugin-lib"; -class BrainEventBus { - private listeners = new Map>>(); - private latest = new Map(); - - on(event: string, listener: Listener): () => void { - if (!this.listeners.has(event)) this.listeners.set(event, new Set()); - this.listeners.get(event)!.add(listener); - if (this.latest.has(event)) listener(this.latest.get(event)); - return () => this.listeners.get(event)?.delete(listener); - } - - emit(event: string, payload: T): void { - this.latest.set(event, payload); - this.listeners.get(event)?.forEach(fn => fn(payload)); - } - - clear(): void { - this.listeners.clear(); - this.latest.clear(); - } -} - -export const brainBus = new BrainEventBus(); +export const brainBus = new EventBus(); export interface BrainStatusEvent { - phase?: "init" | "ingest" | "idle" | "busy"; + status?: "init" | "busy" | "ready" | "error"; statusText?: string; - progress?: number; current?: number; total?: number; - scanning?: boolean; - searching?: boolean; - ingesting?: boolean; - blocked?: boolean; - busy?: boolean; version?: string; error?: string; } diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index 73e9380..3357dc6 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -53,7 +53,7 @@ function calculateIngestTimeout(fileCount: number): number { } /** Unified status updates — see src/status.ts */ -import { updateStatus, initStatus, initVersion, stopStatusServer } from "./status"; +import { updateStatus, initStatus, initVersion, stopStatusServer, toast } from "./status"; @@ -95,20 +95,20 @@ const _serverPlugin = async (input: PluginInput) => { // Fire-and-forget — don't block plugin readiness (async () => { // Signal TUI we're scanning the directory tree - updateStatus("busy", { text: "scanning files", scanning: true, total: 0 }); + updateStatus("busy", { text: "scanning files...", total: 0 }); // Quick preliminary file count for toast + timeout calculation let fileCount = 0; try { const walked = await resolveFiles(directory, true); fileCount = walked.files.length; - updateStatus("busy", { text: "scanning files", scanning: true, total: fileCount }); + updateStatus("busy", { text: `scanning files... ${fileCount}`, total: fileCount }); const timeoutS = (calculateIngestTimeout(fileCount) / 1000).toFixed(0); toast( `Indexing ${fileCount} files… (timeout: ${timeoutS}s)`, "info", "Brain 🧠"); - updateStatus("busy", { text: `ingesting... 0/${fileCount}`, progress: 0, current: 0, total: fileCount, ingesting: true }); + updateStatus("busy", { text: `ingesting... 0/${fileCount}`, current: 0, total: fileCount }); } catch { toast( `Indexing ${project?.name ?? "project"}…`, "info", "Brain 🧠"); - updateStatus("busy", { text: "ingesting...", progress: 0, ingesting: true }); + updateStatus("busy", { text: "ingesting..." }); } const ingestDb = initBrainDatabase(); @@ -120,9 +120,8 @@ const _serverPlugin = async (input: PluginInput) => { reIndex: false, project: directory, progressCallback: ({ current, total }) => { - const pct = total > 0 ? Math.round((current / total) * 100) : 0; // Update status file every tick so TUI spinner stays live - updateStatus("busy", { text: `ingesting... ${current}/${total}`, progress: pct, current, total, ingesting: true }); + updateStatus("busy", { text: `ingesting... ${current}/${total}`, current, total }); }, }), timeoutMs, @@ -248,9 +247,8 @@ const _serverPlugin = async (input: PluginInput) => { reIndex: args.reIndex === true, project: toolCtx.directory, progressCallback: ({ current, total }) => { - const pct = total > 0 ? Math.round((current / total) * 100) : 0; // Update status file every tick so TUI spinner stays live - updateStatus("busy", { text: `ingesting... ${current}/${total}`, progress: pct, current, total, ingesting: true }); + updateStatus("busy", { text: `ingesting... ${current}/${total}`, current, total }); }, }), timeoutMs, @@ -295,7 +293,7 @@ const _serverPlugin = async (input: PluginInput) => { project: s.string().optional().describe("Project name or hash to scope search"), }, execute: async (args, toolCtx) => { - updateStatus("busy", { text: "searching...", searching: true }); + updateStatus("busy", { text: "searching..." }); const db = initBrainDatabase(); try { const results = await withTimeout( diff --git a/src/status.ts b/src/status.ts index 24873f5..c026ecb 100644 --- a/src/status.ts +++ b/src/status.ts @@ -15,16 +15,13 @@ export interface StatusOpts { /** Toast variant (defaults based on state) */ toastVariant?: "info" | "success" | "warning" | "error"; /** Progress info for busy states */ - progress?: number; current?: number; total?: number; - scanning?: boolean; - searching?: boolean; - ingesting?: boolean; } /** Merged state — published via event bus on every update */ -let currentStatus: Record = { phase: "init", version: "" }; +const _state = { current: {} as Record }; +_state.current = { status: "init", statusText: "", version: "" }; let _version = ""; let _client: PluginInput["client"] | null = null; @@ -34,7 +31,7 @@ let _port = 0; /** Initialize with client for toast support */ export function initVersion(v: string): void { _version = v; - write({ phase: "init", statusText: "initializing..." }); + write({ status: "init", statusText: "initializing..." }); } export function initStatus(client: PluginInput["client"], directory: string): void { @@ -51,7 +48,7 @@ export function startStatusServer(directory: string): void { fetch(req) { const url = new URL(req.url); if (url.pathname === "/status") { - const payload = { ...currentStatus, version: _version, updated: Date.now() }; + const payload = { ..._state.current, version: _version, updated: Date.now() }; return new Response(JSON.stringify(payload), { headers: { "Content-Type": "application/json" }, }); @@ -86,8 +83,8 @@ export function stopStatusServer(): void { } function write(data: Record): void { - currentStatus = { ...currentStatus, ...data }; - brainBus.emit("status", { ...currentStatus, version: _version } as BrainStatusEvent); + _state.current = { ..._state.current, ...data }; + brainBus.emit("status", { ..._state.current, version: _version } as BrainStatusEvent); } /** @@ -103,68 +100,48 @@ export function updateStatus(state: StatusState, opts?: StatusOpts): void { switch (state) { case "busy": write({ - phase: "busy", + status: "busy", statusText: opts?.text ?? "", - progress: opts?.progress, current: opts?.current, total: opts?.total, - scanning: opts?.scanning ?? false, - searching: opts?.searching ?? false, - ingesting: opts?.ingesting ?? false, - blocked: false, }); break; case "success": write({ - phase: "idle", + status: "ready", statusText: opts?.text ?? "", - busy: false, - scanning: false, - searching: false, - ingesting: false, }); if (opts?.toast && _client) { - _client?.tui.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: opts.toastVariant ?? "success", duration: 5000 } }); + _client?.tui?.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: opts.toastVariant ?? "success", duration: 5000 } }); } break; case "warning": write({ - phase: "idle", + status: "ready", statusText: opts?.text ?? "", - busy: false, - scanning: false, - searching: false, - ingesting: false, }); if (opts?.toast && _client) { - _client?.tui.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: opts.toastVariant ?? "warning", duration: 5000 } }); + _client?.tui?.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: opts.toastVariant ?? "warning", duration: 5000 } }); } break; case "error": write({ - phase: "idle", + status: "error", statusText: opts?.text ?? "error occurred", - busy: false, - scanning: false, - searching: false, - ingesting: false, + error: opts?.text ?? "error occurred", }); if (opts?.toast && _client) { - _client?.tui.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: "error", duration: 7000 } }); + _client?.tui?.showToast({ body: { title: "Brain 🧠", message: opts.toast, variant: "error", duration: 7000 } }); } break; case "ready": write({ - phase: "idle", + status: "ready", statusText: "", - busy: false, - scanning: false, - searching: false, - ingesting: false, }); break; } @@ -172,5 +149,5 @@ export function updateStatus(state: StatusState, opts?: StatusOpts): void { /** Convenience wrapper for direct toast calls without status file updates */ export function toast(msg: string, variant: "info" | "success" | "warning" | "error" = "info", _title?: string): void { - _client?.tui.showToast({ body: { title: "Brain 🧠", message: msg, variant, duration: variant === "error" ? 7000 : 5000 } }); + _client?.tui?.showToast({ body: { title: "Brain 🧠", message: msg, variant, duration: variant === "error" ? 7000 : 5000 } }); } diff --git a/src/tui.tsx b/src/tui.tsx index f1aa442..76f1493 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -27,50 +27,25 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { setVersion(data.version ?? ""); pulse++; - if (data.error) { + if (data.status === "error") { setBusy(false); setIndicator("•"); - setStatus("error occurred"); + setStatus(data.error || data.statusText || "error occurred"); setFg(theme().error); - return; - } - - if (data.phase === "init") { + } else if (data.status === "init") { setBusy(true); setStatus(data.statusText ?? "initializing..."); - setFg(pulse % 2 === 0 ? theme().warning : theme().accent); - } else if (data.scanning) { - setBusy(true); - setStatus("scanning files... " + (data.total ?? 0)); - setFg(pulse % 2 === 0 ? theme().warning : theme().accent); - } else if (data.ingesting) { - setBusy(true); - setCurrent(data.current ?? 0); - setTotal(data.total ?? 0); - setPct(data.progress ?? 0); - setStatus("ingesting... " + (data.current ?? 0) + "/" + (data.total ?? 0) + " (" + (data.progress ?? 0).toFixed(1) + "%)"); - setFg(pulse % 2 === 0 ? theme().warning : theme().success); - } else if (data.phase === "ingest") { - // fallback for legacy ingest phase + setFg(theme().warning); + } else if (data.status === "busy") { setBusy(true); setCurrent(data.current ?? 0); setTotal(data.total ?? 0); - setPct(data.progress ?? 0); - setStatus("ingesting... " + (data.current ?? 0) + "/" + (data.total ?? 0) + " (" + (data.progress ?? 0).toFixed(1) + "%)"); - setFg(pulse % 2 === 0 ? theme().warning : theme().success); - } else if (data.searching) { - setBusy(true); - setStatus("searching..."); - setFg(pulse % 2 === 0 ? theme().warning : theme().success); - } else if (data.blocked) { - setBusy(false); - setIndicator("•"); - setStatus("ingest excluded"); - setFg(theme().warning); - } else if (data.phase === "idle") { + setStatus(data.statusText ?? "working"); + setFg(pulse % 2 === 0 ? theme().warning : theme().accent); + } else { setBusy(false); setIndicator("•"); - setStatus(data.statusText || "ready"); + setStatus("ready"); setFg(theme().success); } } catch { @@ -108,8 +83,8 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { try { const res = await fetch(statusUrl); if (!res.ok) return; - const data = await res.json(); - handleStatus(data as BrainStatusEvent); + const data = await res.json() as BrainStatusEvent; + handleStatus(data); } catch { /* server not ready */ } };