From d83d13b882f4b77b23c9026a4b9c10151221c2f8 Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 15 Jun 2026 13:36:55 +0200 Subject: [PATCH 1/2] feat: project-scoped bus + immediate auto-ingest + app.log warnings #151 #152 #153 --- package.json | 2 +- src/four-opencode-brain.ts | 180 +++++++++++++++++-------------------- src/status.ts | 20 ++--- 3 files changed, 92 insertions(+), 110 deletions(-) diff --git a/package.json b/package.json index 32fd053..7462bc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", - "version": "1.7.7", +"version": "1.7.9", "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 714db4f..89c77e7 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -91,102 +91,91 @@ const _serverPlugin = async (input: PluginInput) => { try { hasGit = statSync(join(normDir, ".git")).isDirectory(); } catch {} const shouldSkip = !hasGit || isSystemDir; - // Auto-ingest is deferred until session.created — we need a session ID to publish - // status updates on a scoped bus channel. The actual ingest is triggered from the - // "event" hook below. _autoIngestDone ensures it runs exactly once per plugin lifetime. + // Auto-ingest runs immediately at plugin init (not deferred to session.created). + // _autoIngestDone ensures it runs exactly once per plugin lifetime. let _autoIngestDone = false; - /** - * Run the auto-ingest inside a withSessionId(...) context so all updateStatus calls - * publish on brain/{sessionId} — matching the TUI's forSession(sessionId) subscription. - */ - const runAutoIngest = async (sessionID: string) => { - log("info", "auto-ingest", "Auto-ingest starting (deferred)", { directory, sessionID }); - return withSessionId(sessionID, async () => { - // Signal TUI we're scanning the directory tree - 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… ${fileCount}`, total: fileCount }); - const timeoutS = (calculateIngestTimeout(fileCount) / 1000).toFixed(0); - toast( `Indexing ${fileCount} files… (timeout: ${timeoutS}s)`, "info", "Brain 🧠"); - updateStatus("busy", { text: `ingesting…`, current: 0, total: fileCount }); - } catch { - toast( `Indexing ${project?.name ?? "project"}…`, "info", "Brain 🧠"); - updateStatus("busy", { text: "ingesting…" }); - } + const runAutoIngest = async () => { + log("info", "auto-ingest", "Auto-ingest starting", { directory }); + // Signal TUI we're scanning the directory tree + updateStatus("busy", { text: "scanning files…", total: 0 }); - const ingestDb = initBrainDatabase(); - const timeoutMs = calculateIngestTimeout(fileCount); - let lastUpdate = 0; - try { - const result = await withTimeout( - ingestPath(ingestDb, directory!, { - recursive: true, - reIndex: false, - project: directory!, - progressCallback: ({ current, total }) => { - const now = Date.now(); - if (now - lastUpdate < 500 && current !== total) return; // throttle to 0.5s (but always emit final update) - lastUpdate = now; - updateStatus("busy", { text: `ingesting…`, current, total }); - }, - }), - timeoutMs, - `auto-ingest ${directory}`, - ); - if (result.filesFound === 0) { - const dirname = directory!.split("/").filter(Boolean).pop() ?? directory!; - const msg = `🧠 Found 0 files in ${dirname} — check path`; - updateStatus("warning", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); - toast( msg.replace("🧠 ", ""), "warning", "Brain 🧠"); - log("warn", "auto-ingest", msg, { - filesFound: result.filesFound, - filesSkipped: result.filesSkipped, - filesIndexed: result.filesIndexed, - errors: result.errors.length, - durationMs: result.durationMs, - directory, - }); - } else { - const msg = `🧠 Indexed ${result.filesIndexed} new, ${result.filesSkipped} skipped in ${(result.durationMs / 1000).toFixed(1)}s`; - updateStatus("success", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); - toast( msg.replace("🧠 ", ""), "success", "Brain 🧠"); - log("info", "auto-ingest", msg, { - filesFound: result.filesFound, - filesIndexed: result.filesIndexed, - filesSkipped: result.filesSkipped, - errors: result.errors.length, - durationMs: result.durationMs, - directory, - }); - } - } catch (err) { - if (err instanceof TimeoutError) { - const msg = `🧠 Auto-ingest timed out after ${(timeoutMs / 1000).toFixed(0)}s — partial results`; - 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", { text: errMsg.replace("🧠 ", ""), toast: errMsg.replace("🧠 ", "") }); - toast( errMsg.replace("🧠 ", ""), "error", "Brain 🧠"); - log("error", "auto-ingest", errMsg); - } - } finally { - ingestDb.close(); + // 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… ${fileCount}`, total: fileCount }); + const timeoutS = (calculateIngestTimeout(fileCount) / 1000).toFixed(0); + toast(`Indexing ${fileCount} files… (timeout: ${timeoutS}s)`, "info", "Brain 🧠"); + updateStatus("busy", { text: `ingesting…`, current: 0, total: fileCount }); + } catch { + toast(`Indexing ${project?.name ?? "project"}…`, "info", "Brain 🧠"); + updateStatus("busy", { text: "ingesting…" }); + } + + const ingestDb = initBrainDatabase(); + const timeoutMs = calculateIngestTimeout(fileCount); + let lastUpdate = 0; + try { + const result = await withTimeout( + ingestPath(ingestDb, directory!, { + recursive: true, + reIndex: false, + project: directory!, + progressCallback: ({ current, total }) => { + const now = Date.now(); + if (now - lastUpdate < 500 && current !== total) return; + lastUpdate = now; + updateStatus("busy", { text: `ingesting…`, current, total }); + }, + }), + timeoutMs, + `auto-ingest ${directory}`, + ); + if (result.filesFound === 0) { + const dirname = directory!.split("/").filter(Boolean).pop() ?? directory!; + const msg = `🧠 Found 0 files in ${dirname} — check path`; + updateStatus("warning", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); + toast(msg.replace("🧠 ", ""), "warning", "Brain 🧠"); + log("warn", "auto-ingest", msg, { filesFound: result.filesFound, filesSkipped: result.filesSkipped, filesIndexed: result.filesIndexed, errors: result.errors.length, durationMs: result.durationMs, directory }); + } else { + const msg = `🧠 Indexed ${result.filesIndexed} new, ${result.filesSkipped} skipped in ${(result.durationMs / 1000).toFixed(1)}s`; + updateStatus("success", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); + toast(msg.replace("🧠 ", ""), "success", "Brain 🧠"); + log("info", "auto-ingest", msg, { filesFound: result.filesFound, filesIndexed: result.filesIndexed, filesSkipped: result.filesSkipped, errors: result.errors.length, durationMs: result.durationMs, directory }); } - }); + } catch (err) { + if (err instanceof TimeoutError) { + const msg = `🧠 Auto-ingest timed out after ${(timeoutMs / 1000).toFixed(0)}s — partial results`; + 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", { text: errMsg.replace("🧠 ", ""), toast: errMsg.replace("🧠 ", "") }); + toast(errMsg.replace("🧠 ", ""), "error", "Brain 🧠"); + log("error", "auto-ingest", errMsg); + } + } finally { + ingestDb.close(); + } }; if (autoIngest && shouldSkip) { log("warn", "auto-ingest", "Skipped — not a git repo or system dir: " + normDir); updateStatus("warning", { text: "ingest excluded" }); } + + // Trigger auto-ingest immediately (fire-and-forget, not deferred to session.created). + // updateStatus("ready") is NOT called here — auto-ingest transitions to ready/success/error on its own. + if (autoIngest && !shouldSkip && !_autoIngestDone) { + _autoIngestDone = true; + runAutoIngest().catch((err) => { + log("error", "auto-ingest", `Auto-ingest failed: ${String(err)}`); + }); + } + // Install slash commands on first run (silent unless error) try { installBrainCommands(); @@ -221,9 +210,11 @@ const _serverPlugin = async (input: PluginInput) => { firstRunDb.close(); } - // Init complete — plugin is ready for tool calls. Auto-ingest (if any) runs in background - // and will transition to "busy" → "ready" on its own. - updateStatus("ready"); + // Init complete — set ready ONLY if auto-ingest is not running. + // Auto-ingest transitions to ready/success/error on its own via updateStatus(). + if (!_autoIngestDone) { + updateStatus("ready"); + } // ---- Tool definitions ---- @@ -736,20 +727,11 @@ const _serverPlugin = async (input: PluginInput) => { } }, "event": async (eventInput) => { - // Capture session ID as soon as a session exists — this enables forSession(sid) - // publishes from updateStatus(). Also triggers deferred auto-ingest exactly once, - // wrapped in withSessionId(sid) so its status updates land on the right channel. + // Capture session ID as soon as a session exists. if (eventInput.event.type === "session.created") { const { sessionID } = eventInput.event.properties as { sessionID?: string }; if (sessionID) { setSessionId(sessionID); - if (!_autoIngestDone && autoIngest && directory && !shouldSkip) { - _autoIngestDone = true; - // Fire-and-forget — don't block the event hook - runAutoIngest(sessionID).catch((err) => { - log("error", "auto-ingest", `Deferred auto-ingest failed: ${String(err)}`); - }); - } } return; } diff --git a/src/status.ts b/src/status.ts index 946518e..8cf15fa 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,7 +4,7 @@ import { homedir } from "os"; import { join } from "path"; import { AsyncLocalStorage } from "node:async_hooks"; import type { PluginInput } from "@opencode-ai/plugin"; -import { BusClient } from "@four-bytes/opencode-plugin-lib"; +import { BusClient, deriveProjectId } from "@four-bytes/opencode-plugin-lib"; import type { BrainStatusEvent } from "./event-bus"; export type StatusState = "busy" | "success" | "warning" | "error" | "ready"; @@ -40,6 +40,7 @@ export function withSessionId(id: string, fn: () => Promise): Promise { return _sessionAls.run(id, fn); } +let _directory = ""; let _client: PluginInput["client"] | null = null; let _server: ReturnType | null = null; let _port = 0; @@ -74,6 +75,7 @@ export function setSessionId(id: string): void { export function initStatus(client: PluginInput["client"], directory: string): void { _client = client; + _directory = directory; startStatusServer(directory); } @@ -134,19 +136,17 @@ export function stopStatusServer(): void { function write(data: Record): void { _state.current = { ..._state.current, ...data }; - // ALS-stored session ID wins over global (prevents cross-session channel overwrite). - // If no session ID is available, skip the bus publish entirely — the TUI only - // subscribes on forSession(sid), so there is no unscoped consumer to receive on. - // The HTTP /status endpoint still serves polling clients. - const sid = _sessionAls.getStore() ?? ""; - const payload = { ..._state.current, version: _version, sessionId: sid || undefined } as BrainStatusEvent; + // Publish to project-scoped bus channel — brain status is project-wide, not session-scoped. + // The TUI subscribes on forProject(projectId), so sessions share the same status feed. + const projectId = _directory ? deriveProjectId(_directory) : ""; + const payload = { ..._state.current, version: _version } as BrainStatusEvent; - if (!sid) return; + if (!projectId) return; - // Real-time push via scoped plugin bus (HTTP fallback still serves status endpoint) + // Real-time push via project-scoped plugin bus (HTTP /status endpoint still serves polling clients) getBus() .then(async (bus) => { - const target = bus.forService("brain").forSession(sid); + const target = bus.forService("brain").forProject(projectId); await target.publish("status", payload); }) .catch((err) => { From 049e9251c3fad2ad024e1fdf44db8c824a760296 Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 15 Jun 2026 14:59:58 +0200 Subject: [PATCH 2/2] fix: inline deriveProjectId + TUI project-scoped subscription #155 --- package.json | 2 +- src/status.ts | 16 +++++++++++++++- src/tui.tsx | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 7462bc5..3cf4403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", -"version": "1.7.9", +"version": "1.7.10", "description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base", "license": "Apache-2.0", "type": "module", diff --git a/src/status.ts b/src/status.ts index 8cf15fa..b508300 100644 --- a/src/status.ts +++ b/src/status.ts @@ -4,9 +4,23 @@ import { homedir } from "os"; import { join } from "path"; import { AsyncLocalStorage } from "node:async_hooks"; import type { PluginInput } from "@opencode-ai/plugin"; -import { BusClient, deriveProjectId } from "@four-bytes/opencode-plugin-lib"; +import { BusClient } from "@four-bytes/opencode-plugin-lib"; import type { BrainStatusEvent } from "./event-bus"; +/** + * Derives a stable project ID from a directory path. + * Uses FNV-1a 32-bit hash — works in both Bun (server) and TUI (browser). + * Inlined from @four-bytes/opencode-plugin-lib to avoid npm publish dependency. + */ +function deriveProjectId(directory: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < directory.length; i++) { + h ^= directory.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, "0"); +} + export type StatusState = "busy" | "success" | "warning" | "error" | "ready"; export interface StatusOpts { diff --git a/src/tui.tsx b/src/tui.tsx index a95572d..082917b 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -8,6 +8,20 @@ import { ProgressBar } from "@four-bytes/opencode-plugin-lib/tui-components"; import type { BrainStatusEvent } from "./event-bus"; import { Spinner } from "./spinner"; +/** + * Derives a stable project ID from a directory path. + * Uses FNV-1a 32-bit hash — works in both Bun (server) and TUI (browser). + * Inlined from @four-bytes/opencode-plugin-lib to avoid npm publish dependency. + */ +function deriveProjectId(directory: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < directory.length; i++) { + h ^= directory.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, "0"); +} + function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; sessionId?: string }) { const [statusText, setStatusText] = createSignal("connecting…"); const [version, setVersion] = createSignal(""); @@ -61,8 +75,8 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; }; - // Reactive bus subscription — re-subscribes on session change, cleans up on unmount. - useServiceBus("brain", () => props.sessionId, "status", (payload) => { + // Reactive bus subscription — project-scoped (brain status is project-wide, not session-scoped). + useServiceBus("brain", () => deriveProjectId(props.api.state.path.directory), "status", (payload) => { handleStatus(payload as BrainStatusEvent); });