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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@four-bytes/four-opencode-brain",
"version": "1.7.7",
"version": "1.7.10",
"description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base",
"license": "Apache-2.0",
"type": "module",
Expand Down
180 changes: 81 additions & 99 deletions src/four-opencode-brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
});
Comment on lines +174 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Auto-ingest top-level rejection path only logs and does not update status, so startup can be stuck in busy state. Handle the catch by publishing an error status.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/four-opencode-brain.ts, line 174:

<comment>Auto-ingest top-level rejection path only logs and does not update status, so startup can be stuck in busy state. Handle the catch by publishing an error status.</comment>

<file context>
@@ -91,102 +91,91 @@ const _serverPlugin = async (input: PluginInput) => {
+  // 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)}`);
+    });
</file context>
Suggested change
runAutoIngest().catch((err) => {
log("error", "auto-ingest", `Auto-ingest failed: ${String(err)}`);
});
runAutoIngest().catch((err) => {
const errMsg = `Auto-ingest failed: ${String(err)}`;
log("error", "auto-ingest", errMsg);
updateStatus("error", { text: errMsg, toast: errMsg });
});

}

// Install slash commands on first run (silent unless error)
try {
installBrainCommands();
Expand Down Expand Up @@ -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 ----

Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 23 additions & 9 deletions src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ import type { PluginInput } from "@opencode-ai/plugin";
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 {
Expand Down Expand Up @@ -40,6 +54,7 @@ export function withSessionId<T>(id: string, fn: () => Promise<T>): Promise<T> {
return _sessionAls.run(id, fn);
}

let _directory = "";
let _client: PluginInput["client"] | null = null;
let _server: ReturnType<typeof Bun.serve> | null = null;
let _port = 0;
Expand Down Expand Up @@ -74,6 +89,7 @@ export function setSessionId(id: string): void {

export function initStatus(client: PluginInput["client"], directory: string): void {
_client = client;
_directory = directory;
startStatusServer(directory);
}

Expand Down Expand Up @@ -134,19 +150,17 @@ export function stopStatusServer(): void {

function write(data: Record<string, unknown>): 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) => {
Expand Down
18 changes: 16 additions & 2 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
});

Expand Down
Loading