From 14552f9cd3d8bceb7ba625347b589a9d64fa446b Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 8 Jun 2026 14:28:45 +0200 Subject: [PATCH] fix: restore file I/O for cross-context status + TUI fixes + v1.6.0 #102 - Restore getBrainStatusFile + file write in status.ts (opencode Worker architecture) - TUI polls file every 200ms alongside event bus (fast-path) - TUI idle handler uses data.statusText instead of hardcoded 'ready' - 16 updateStatus calls now include text: alongside toast: - Version bumped to 1.6.0 (minor, event bus functionality) - ROADMAP A2.1 updated with architecture note --- ROADMAP.md | 2 +- package.json | 2 +- src/four-opencode-brain.ts | 34 +++++++++++++++++----------------- src/shared.ts | 9 ++++++++- src/status.ts | 18 +++++++++++++++--- src/tui.tsx | 20 +++++++++++++++++--- 6 files changed, 59 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 552a22e..eea750c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,7 @@ import { BrainStatusBar } from "@four-bytes/brain-tui"; - [x] In `src/tui.tsx`: subscribe to event bus instead of `setInterval(poll, 200ms)` - [x] Remove `POLL_MS`, `setInterval`, `onCleanup(clearInterval)` polling loop - [x] Remove file-based `writeFileSync` / `readFile` status mechanism -- [x] **Verification:** Status bar updates in real time; no file I/O for status +- [x] **Verification:** Status bar updates in real time. Note: file I/O retained as opencode runs server/TUI in separate Worker contexts — event bus is fast-path within same context, file is cross-context fallback. #### A2.2 — Use opencode `` component - [x] Remove `const SPINNER = ["⠋","⠙",…]` + `spin` variable from `tui.tsx` diff --git a/package.json b/package.json index 25af1ef..408ccbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", - "version": "1.5.1", + "version": "1.6.0", "description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base", "license": "Apache-2.0", "type": "module", diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index f27864a..83cc7e1 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -62,7 +62,7 @@ const _serverPlugin = async (input: PluginInput) => { const { client, project, directory, $ } = input; sessionCache.reset(); - initStatus(client); + initStatus(client, directory); initVersion(VERSION); const toast = createToast(client, "Brain 🧠"); // replaced inline with @four-bytes/opencode-plugin-lib log("info", "init", `v${VERSION} loaded`, { pid: process.pid }); @@ -135,7 +135,7 @@ const _serverPlugin = async (input: PluginInput) => { if (result.filesFound === 0) { const dirname = directory.split("/").filter(Boolean).pop() ?? directory; const msg = `🧠 Found 0 files in ${dirname} — check path`; - updateStatus("warning", { toast: msg.replace("🧠 ", "") }); + updateStatus("warning", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); toast( msg.replace("🧠 ", ""), "warning", "Brain 🧠"); log("warn", "auto-ingest", msg, { filesFound: result.filesFound, @@ -147,7 +147,7 @@ const _serverPlugin = async (input: PluginInput) => { }); } else { const msg = `🧠 Indexed ${result.filesIndexed} new, ${result.filesSkipped} skipped in ${(result.durationMs / 1000).toFixed(1)}s`; - updateStatus("success", { toast: msg.replace("🧠 ", "") }); + updateStatus("success", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); toast( msg.replace("🧠 ", ""), "success", "Brain 🧠"); log("info", "auto-ingest", msg, { filesFound: result.filesFound, @@ -161,12 +161,12 @@ const _serverPlugin = async (input: PluginInput) => { } catch (err) { if (err instanceof TimeoutError) { const msg = `🧠 Auto-ingest timed out after ${(timeoutMs / 1000).toFixed(0)}s — partial results`; - updateStatus("warning", { toast: msg.replace("🧠 ", "") }); + updateStatus("warning", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); toast( msg.replace("🧠 ", ""), "warning", "Brain 🧠"); log("warn", "auto-ingest", msg, { directory, timeoutMs }); } else { const errMsg = `🧠 Auto-ingest failed: ${String(err)}`; - updateStatus("error", { toast: errMsg.replace("🧠 ", "") }); + updateStatus("error", { text: errMsg.replace("🧠 ", ""), toast: errMsg.replace("🧠 ", "") }); toast( errMsg.replace("🧠 ", ""), "error", "Brain 🧠"); log("error", "auto-ingest", errMsg); } @@ -269,19 +269,19 @@ const _serverPlugin = async (input: PluginInput) => { durationMs: result.durationMs, }, }); - updateStatus("success", { toast: msg.replace("🧠 ", "") }); + updateStatus("success", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); return JSON.stringify(result); } catch (err) { if (err instanceof TimeoutError) { const timeoutMsg = `🧠 Ingest timed out after ${(timeoutMs / 1000).toFixed(0)}s`; toolCtx.metadata({ title: timeoutMsg }); - updateStatus("warning", { toast: timeoutMsg.replace("🧠 ", "") }); + updateStatus("warning", { text: timeoutMsg.replace("🧠 ", ""), toast: timeoutMsg.replace("🧠 ", "") }); log("warn", "ingest-timeout", timeoutMsg, { path: resolvedPath }); return JSON.stringify({ error: timeoutMsg, partial: true }); } const errMsg = `🧠 Ingest error: ${String(err)}`; toolCtx.metadata({ title: errMsg }); - updateStatus("error", { toast: errMsg.replace("🧠 ", "") }); + updateStatus("error", { text: errMsg.replace("🧠 ", ""), toast: errMsg.replace("🧠 ", "") }); return JSON.stringify({ error: String(err) }); } finally { db.close(); @@ -362,7 +362,7 @@ const _serverPlugin = async (input: PluginInput) => { embedded = await embedChunks(db, chunkIds); } - updateStatus("success", { toast: `Vector index rebuilt: ${embedded}/${totalChunks} chunks` }); + updateStatus("success", { text: `Vector index rebuilt: ${embedded}/${totalChunks} chunks`, toast: `Vector index rebuilt: ${embedded}/${totalChunks} chunks` }); return JSON.stringify({ ok: true, chunks: totalChunks, @@ -411,7 +411,7 @@ const _serverPlugin = async (input: PluginInput) => { tags: args.tags as string | undefined, project: args.project as string | undefined, }); - updateStatus("success", { toast: "Memory stored" }); + updateStatus("success", { text: "Memory stored", toast: "Memory stored" }); return JSON.stringify(addResult); case "search": return JSON.stringify( @@ -436,7 +436,7 @@ const _serverPlugin = async (input: PluginInput) => { case "forget": updateStatus("busy", { text: "Removing memory…" }); const forgetOk = memoryForget(db, args.id as string); - updateStatus(forgetOk ? "success" : "error", { toast: forgetOk ? "Memory removed" : "Memory not found" }); + updateStatus(forgetOk ? "success" : "error", { text: forgetOk ? "Memory removed" : "Memory not found", toast: forgetOk ? "Memory removed" : "Memory not found" }); return JSON.stringify({ ok: forgetOk }); case "diary": { // Auto-detect: if title + content provided → add entry; otherwise → get @@ -522,10 +522,10 @@ const _serverPlugin = async (input: PluginInput) => { confidence: args.confidence as number | undefined, review_state: args.review_state as string | undefined, } satisfies KbAddInput); - updateStatus("success", { toast: "Knowledge entry saved" }); + updateStatus("success", { text: "Knowledge entry saved", toast: "Knowledge entry saved" }); return JSON.stringify(result); } catch (err) { - updateStatus("error", { toast: `kb_add failed` }); + updateStatus("error", { text: `kb_add failed`, toast: `kb_add failed` }); const errMsg = `kb_add failed: ${err instanceof Error ? err.message : String(err)}`; log("error", "kb-add", errMsg, { entry_key: args.entry_key ?? deriveEntryKey(args.title as string) }); return JSON.stringify({ error: errMsg }); @@ -561,10 +561,10 @@ const _serverPlugin = async (input: PluginInput) => { observed_symptoms: args.observed_symptoms as string | undefined, outcome: args.outcome as "fixed" | "failed" | "workaround" | "observed", } satisfies KbRecordInput); - updateStatus("success", { toast: "Occurrence recorded" }); + updateStatus("success", { text: "Occurrence recorded", toast: "Occurrence recorded" }); return JSON.stringify(occurrence); } catch (err) { - updateStatus("error", { toast: `kb_record failed` }); + updateStatus("error", { text: `kb_record failed`, toast: `kb_record failed` }); const errMsg = `kb_record failed: ${err instanceof Error ? err.message : String(err)}`; log("error", "kb-record", errMsg, { entry_key: args.entry_key, kind: args.kind }); return JSON.stringify({ error: errMsg }); @@ -592,10 +592,10 @@ const _serverPlugin = async (input: PluginInput) => { review_state: args.review_state as "draft" | "reviewed" | "accepted" | "rejected" | "superseded", confidence: args.confidence as number | undefined, } satisfies KbReviewInput); - updateStatus("success", { toast: "Review updated" }); + updateStatus("success", { text: "Review updated", toast: "Review updated" }); return JSON.stringify(entry); } catch (err) { - updateStatus("error", { toast: `kb_review failed` }); + updateStatus("error", { text: `kb_review failed`, toast: `kb_review failed` }); const errMsg = `kb_review failed: ${err instanceof Error ? err.message : String(err)}`; log("error", "kb-review", errMsg, { entry_key: args.entry_key, kind: args.kind }); return JSON.stringify({ error: errMsg }); diff --git a/src/shared.ts b/src/shared.ts index a523632..b6a1d82 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1 +1,8 @@ -// shared.ts — emptied after migrating from file-based status to event bus +import { createHash } from "crypto"; +import { homedir } from "os"; +import { join } from "path"; + +export function getBrainStatusFile(directory: string): string { + const hash = createHash("md5").update(directory).digest("hex").slice(0, 12); + return join(homedir(), ".cache", "opencode", "brain", `status-${hash}.json`); +} diff --git a/src/status.ts b/src/status.ts index ce85638..fe0385d 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,6 +1,8 @@ +import { writeFileSync, mkdirSync, existsSync } from "fs"; import { createToast } from "@four-bytes/opencode-plugin-lib"; import type { PluginInput } from "@opencode-ai/plugin"; import { brainBus, type BrainStatusEvent } from "./event-bus"; +import { getBrainStatusFile } from "./shared"; export type StatusState = "busy" | "success" | "warning" | "error" | "ready"; @@ -22,20 +24,30 @@ let currentStatus: Record = { phase: "init", version: "" }; let _version = ""; let toastFn: ReturnType | null = null; +let _statusFile = ""; /** Initialize with client for toast support */ export function initVersion(v: string): void { _version = v; - write({ phase: "init", statusText: "initializing ..." }); + write({ phase: "init", statusText: "initializing..." }); } -export function initStatus(client: PluginInput["client"]): void { +export function initStatus(client: PluginInput["client"], directory: string): void { toastFn = createToast(client, "Brain 🧠"); + _statusFile = getBrainStatusFile(directory); } function write(data: Record): void { currentStatus = { ...currentStatus, ...data }; - brainBus.emit("status", { ...currentStatus, version: _version } as BrainStatusEvent); + const payload = { ...currentStatus, version: _version, updated: Date.now() }; + brainBus.emit("status", payload as BrainStatusEvent); + try { + if (_statusFile) { + const dir = _statusFile.replace(/\/[^/]+$/, ""); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(_statusFile, JSON.stringify(payload)); + } + } catch { /* never crash on status file failure */ } } /** diff --git a/src/tui.tsx b/src/tui.tsx index 4c6d4ea..a3dc8a1 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -4,6 +4,7 @@ import { createSignal, onMount, onCleanup } from "solid-js"; import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; import type { RGBA } from "@opentui/core"; import { brainBus, type BrainStatusEvent } from "./event-bus"; +import { getBrainStatusFile } from "./shared"; import { Spinner } from "./spinner"; function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { @@ -42,7 +43,7 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { setFg(pulse % 2 === 0 ? theme().warning : theme().accent); } else if (data.phase === "init") { setBusy(true); - setStatus("initializing"); + setStatus(data.statusText ?? "initializing..."); setFg(pulse % 2 === 0 ? theme().warning : theme().accent); } else if (data.phase === "ingest") { setBusy(true); @@ -63,7 +64,7 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { } else if (data.phase === "idle") { setBusy(false); setIndicator("•"); - setStatus("ready"); + setStatus(data.statusText || "ready"); setFg(theme().success); } } catch { @@ -76,7 +77,18 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { onMount(() => { const unsub = brainBus.on("status", handleStatus); - onCleanup(() => unsub()); + const poll = async () => { + try { + const statusFile = getBrainStatusFile(props.api.state.path.directory); + const file = Bun.file(statusFile); + if (!(await file.exists())) return; + const data = await file.json(); + handleStatus(data as BrainStatusEvent); + } catch { /* silent */ } + }; + poll(); + const timer = setInterval(poll, 200); + onCleanup(() => { unsub(); clearInterval(timer); }); }); const StatusRow = () => ( @@ -114,6 +126,8 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { ); } +export { BrainStatusBar }; + const tui: TuiPlugin = (api) => { api.slots.register({ order: 60, // below deepseek-meter (55)