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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Spinner>` component
- [x] Remove `const SPINNER = ["⠋","⠙",…]` + `spin` variable from `tui.tsx`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
34 changes: 17 additions & 17 deletions src/four-opencode-brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
9 changes: 8 additions & 1 deletion src/shared.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
18 changes: 15 additions & 3 deletions src/status.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,20 +24,30 @@ let currentStatus: Record<string, unknown> = { phase: "init", version: "" };
let _version = "";

let toastFn: ReturnType<typeof createToast> | 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<string, unknown>): 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 */ }
}

/**
Expand Down
20 changes: 17 additions & 3 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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 = () => (
Expand Down Expand Up @@ -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)
Expand Down
Loading