diff --git a/README.md b/README.md
index 79b0edbf..61ab0e62 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,8 @@ graph LR
KG --> D
E["JSONL conversations"] --> W["Real-time Watcher
~1s latency"]
W --> D
- I["BrainBar
macOS menu bar"] -->|Unix socket| B
+ I["BrainBar UI
NSStatusItem + NSPopover"] -->|UDS /tmp/brainbar.sock| BB["BrainBarDaemon
MCP + brain bus"]
+ BB -->|MCP socket protocol| B
```
**Everything runs locally.** Cloud enrichment (Gemini/Groq) and Axiom telemetry are optional.
@@ -138,13 +139,22 @@ graph LR
## BrainBar — macOS Companion
-Optional native Swift menu bar app. Quick capture, live dashboard, knowledge graph viewer — all over a Unix socket. Auto-restarts after quit via LaunchAgent.
+Optional native Swift menu bar companion split into two launchd-managed processes:
+
+```mermaid
+flowchart LR
+ UI["BrainBar
LSUIElement UI"] -->|"watch-brain-bus + commands
/tmp/brainbar.sock"| D["BrainBarDaemon
headless MCP server"]
+ D -->|"single writer queue + reads"| DB["SQLite WAL
~/.local/share/brainlayer/brainlayer.db"]
+ D -->|"helper subprocess IPC"| H["Hybrid search helper"]
+```
+
+`BrainBarDaemon` owns the MCP server, `/tmp/brainbar.sock`, the single-writer path, the `watch-brain-bus` stream, and helper subprocess lifecycle. `BrainBar` owns only the `NSStatusItem`, transient `NSPopover`, SwiftUI surfaces, hotkey routing, and a reconnecting socket subscriber. Killing the UI does not stop the daemon socket.
```bash
bash brain-bar/build-app.sh # Build, sign, install LaunchAgent
```
-Requires the BrainLayer MCP server. The build script refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds.
+The build script builds both `BrainBar` and `BrainBarDaemon`, embeds both binaries in `BrainBar.app`, then installs `com.brainlayer.brainbar.plist` and `com.brainlayer.brainbar-daemon.plist` with `ProcessType=Interactive`. It refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds.
## Writer Arbitration
diff --git a/brain-bar/Package.swift b/brain-bar/Package.swift
index d362124a..8a996a0f 100644
--- a/brain-bar/Package.swift
+++ b/brain-bar/Package.swift
@@ -6,6 +6,10 @@ let package = Package(
platforms: [
.macOS(.v14),
],
+ products: [
+ .executable(name: "BrainBar", targets: ["BrainBar"]),
+ .executable(name: "BrainBarDaemon", targets: ["BrainBarDaemon"]),
+ ],
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.5.0"),
],
@@ -20,6 +24,16 @@ let package = Package(
.linkedLibrary("sqlite3"),
]
),
+ .executableTarget(
+ name: "BrainBarDaemon",
+ dependencies: [
+ .product(name: "GRDB", package: "GRDB.swift"),
+ ],
+ path: "Sources/BrainBarDaemon",
+ linkerSettings: [
+ .linkedLibrary("sqlite3"),
+ ]
+ ),
.testTarget(
name: "BrainBarTests",
dependencies: [
diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift
index 4b17bea3..ebd2b94d 100644
--- a/brain-bar/Sources/BrainBar/BrainBarApp.swift
+++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift
@@ -7,6 +7,34 @@ enum BrainBarAppSupport {
static func hotkeyPermissionFailureMessage(permissions: HotkeyPermissionStatus) -> String {
"BrainBar could not start the fallback hotkey listener. Enable \(permissions.missingPermissionsMessage) in System Settings. The CGEventTap fallback requires both Input Monitoring and Accessibility."
}
+
+ @MainActor
+ static func makeStatsCollector(
+ dbPath: String,
+ targetPID: pid_t,
+ brainBusEvents: BrainBusEventSource? = BrainBusClient(),
+ databaseOpenConfiguration: BrainDatabase.OpenConfiguration = BrainDatabase.OpenConfiguration()
+ ) -> StatsCollector {
+ StatsCollector(
+ dbPath: dbPath,
+ daemonMonitor: DaemonHealthMonitor(targetPID: targetPID),
+ brainBusEvents: brainBusEvents,
+ databaseOpenConfiguration: databaseOpenConfiguration
+ )
+ }
+
+ @MainActor
+ static func makeUIStatsCollector(
+ dbPath: String,
+ brainBusEvents: BrainBusEventSource? = BrainBusClient()
+ ) -> StatsCollector {
+ makeStatsCollector(
+ dbPath: dbPath,
+ targetPID: 0,
+ brainBusEvents: brainBusEvents,
+ databaseOpenConfiguration: BrainDatabase.OpenConfiguration(readOnly: true)
+ )
+ }
}
@MainActor
@@ -14,19 +42,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let runtime = BrainBarRuntime()
private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow"
- private var server: BrainBarServer?
+ private var statusPopoverController: BrainBarStatusPopoverController?
private var legacyStatusItem: NSStatusItem?
private var legacyPopover: NSPopover?
private var collector: StatsCollector?
- private var injectionStore: InjectionStore?
- private var quickCapturePanel: QuickCapturePanelController?
private var searchPanel: SearchPanelController?
private var dashboardPanel: BrainBarDashboardPanelController?
private var quickCaptureHotkey: HotkeyManager?
private weak var menuBarExtraWindow: NSWindow?
private weak var discoveredMenuBarWindow: NSWindow?
private var cancellables: Set = []
- private var sharedDatabase: BrainDatabase?
private var pendingBrainBarURLs: [URL] = []
private var hotkeyFileWatcher: DispatchSourceFileSystemObject?
private var menuBarWindowObservers: [NSObjectProtocol] = []
@@ -47,11 +72,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
startHotkeyFileWatcher()
- if launchMode == .menuBarWindow {
- UserDefaults.standard.removeObject(forKey: Self.menuBarWindowAutosaveKey)
- dashboardPanel = BrainBarDashboardPanelController(runtime: runtime)
- }
-
let runningInstances = NSRunningApplication.runningApplications(
withBundleIdentifier: Bundle.main.bundleIdentifier ?? "com.brainlayer.BrainBar"
)
@@ -69,47 +89,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.configureQuickCaptureHotkey()
}
- if launchMode == .legacyStatusItem {
+ if launchMode == .menuBarWindow {
+ statusPopoverController = BrainBarStatusPopoverController(runtime: runtime)
+ } else if launchMode == .legacyStatusItem {
createLegacyStatusItem()
}
let dbPath = BrainBarServer.defaultDBPath()
- NSLog("[BrainBar] Starting server before database readiness at %@", dbPath)
- let server = BrainBarServer(dbPath: dbPath)
- server.onStartRejected = { reason in
- NSLog("[BrainBar] Startup rejected: %@", reason)
- Task { @MainActor in
- NSApp.terminate(nil)
- }
- }
- server.onDatabaseReady = { [weak self] database in
- Task { @MainActor in
- guard let self else { return }
- self.sharedDatabase = database
- self.configureQuickCapture(database: database)
- self.runtime.install(
- collector: self.collector ?? StatsCollector(
- dbPath: dbPath,
- daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier)
- ),
- injectionStore: self.injectionStore,
- database: database
- )
- self.flushPendingBrainBarURLs()
- }
- }
-
- let collector = StatsCollector(
+ NSLog("[BrainBar] Starting UI shell; daemon owns %@", BrainBarServer.defaultSocketPath())
+ let collector = BrainBarAppSupport.makeUIStatsCollector(
dbPath: dbPath,
- daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier)
+ brainBusEvents: BrainBusClient()
)
- let injectionStore = try? InjectionStore(databasePath: dbPath)
-
- self.server = server
self.collector = collector
- self.injectionStore = injectionStore
+ runtime.install(
+ collector: collector,
+ injectionStore: nil,
+ database: nil
+ )
- server.start()
+ flushPendingBrainBarURLs()
if launchMode == .legacyStatusItem {
installLegacyMenuBarSurface(with: collector)
@@ -123,13 +122,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
menuBarWindowObservers.forEach(NotificationCenter.default.removeObserver)
menuBarWindowObservers.removeAll()
+ statusPopoverController?.stop()
+ statusPopoverController = nil
menuBarWindowSyncTask?.cancel()
menuBarWindowSyncTask = nil
hotkeyFileWatcher?.cancel()
quickCaptureHotkey?.stop()
collector?.stop()
- injectionStore?.stop()
- server?.stop()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -147,17 +146,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
runtime.presentQuickAction(.search)
- showMenuBarWindow(nil)
+ statusPopoverController?.show(nil)
}
func showQuickCapturePanel() {
guard launchMode == .menuBarWindow else {
- quickCapturePanel?.toggle()
+ NSLog("[BrainBar] Quick capture requires the menu-bar command surface in UI-split mode")
return
}
runtime.presentQuickAction(.capture)
- showMenuBarWindow(nil)
+ statusPopoverController?.show(nil)
}
private func configureRuntimeCallbacks() {
@@ -236,6 +235,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
+ if launchMode == .menuBarWindow, let statusPopoverController {
+ statusPopoverController.toggle(sender)
+ return
+ }
+
if let dashboardPanel {
dashboardPanel.toggle()
return
@@ -284,10 +288,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
+ if let statusPopoverController {
+ statusPopoverController.show(nil)
+ return
+ }
+
dashboardPanel?.show()
}
private func showMenuBarWindow(_ sender: Any?) {
+ if launchMode == .menuBarWindow, let statusPopoverController {
+ statusPopoverController.show(sender)
+ return
+ }
+
if launchMode == .menuBarWindow, let dashboardPanel {
dashboardPanel.show()
return
@@ -797,16 +811,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
- private func configureQuickCapture(database: BrainDatabase) {
- guard launchMode == .legacyStatusItem else { return }
- guard quickCapturePanel == nil else { return }
- quickCapturePanel = QuickCapturePanelController(db: database)
- if searchPanel == nil {
- searchPanel = SearchPanelController(db: database)
- }
- flushPendingBrainBarURLs()
- }
-
private func ingestBrainBarURLs(_ urls: [URL]) {
for url in urls {
guard BrainBarURLAction.parse(url: url) != nil else { continue }
@@ -827,18 +831,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
- /// URL actions are dispatched once the backing surface is ready. In
- /// menuBarWindow mode the command bar is driven by `runtime.database` +
- /// the MenuBarExtra window, so readiness means the database has been
- /// installed into the runtime. In legacy mode the floating panel still
- /// drives routing.
private func isReadyToHandleBrainBarURL() -> Bool {
- switch launchMode {
- case .menuBarWindow:
- return runtime.database != nil
- case .legacyStatusItem:
- return quickCapturePanel != nil
- }
+ true
}
private func handleBrainBarURL(_ url: URL) {
@@ -859,26 +853,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct BrainBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
- private let launchMode = BrainBarLaunchMode.resolve()
var body: some Scene {
- MenuBarExtra(isInserted: .constant(launchMode == .menuBarWindow)) {
- Button("Open Dashboard") {
- appDelegate.showDashboardPanel()
- }
-
- Button("Search BrainLayer") {
- appDelegate.showSearchPanel()
- }
-
- Button("Capture Note") {
- appDelegate.showQuickCapturePanel()
- }
- } label: {
- BrainBarMenuBarLabel(runtime: appDelegate.runtime)
- }
- .menuBarExtraStyle(.menu)
-
Settings {
EmptyView()
}
@@ -900,27 +876,3 @@ struct BrainBarApp: App {
}
}
}
-
-private struct BrainBarMenuBarLabel: View {
- @ObservedObject var runtime: BrainBarRuntime
-
- var body: some View {
- if let collector = runtime.collector {
- let livePresentation = BrainBarLivePresentation.derive(stats: collector.stats)
- HStack(spacing: 6) {
- Image(systemName: "brain")
- Image(
- nsImage: SparklineRenderer.render(
- state: collector.state,
- values: collector.stats.recentEnrichmentBuckets,
- size: NSSize(width: 22, height: 12),
- accentColor: livePresentation.accentColor
- )
- )
- .interpolation(.high)
- }
- } else {
- Image(systemName: "brain")
- }
- }
-}
diff --git a/brain-bar/Sources/BrainBar/BrainBarRuntime.swift b/brain-bar/Sources/BrainBar/BrainBarRuntime.swift
index 0068446d..a21ee3bc 100644
--- a/brain-bar/Sources/BrainBar/BrainBarRuntime.swift
+++ b/brain-bar/Sources/BrainBar/BrainBarRuntime.swift
@@ -26,7 +26,7 @@ final class BrainBarRuntime: ObservableObject {
func install(
collector: StatsCollector,
injectionStore: InjectionStore?,
- database: BrainDatabase
+ database: BrainDatabase?
) {
self.collector = collector
self.injectionStore = injectionStore
diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift
index 02366bdc..6a54dfc0 100644
--- a/brain-bar/Sources/BrainBar/BrainBarServer.swift
+++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift
@@ -92,12 +92,19 @@ final class BrainBarServer: @unchecked Sendable {
private let providedDatabase: BrainDatabase?
private let databaseRecoveryPolicy: DatabaseRecoveryPolicy
private let instanceLockPath: String
+ private static let queueKey = DispatchSpecificKey()
private let queue = DispatchQueue(label: "com.brainlayer.brainbar.server", qos: .userInitiated)
+ private let queueID = UUID()
private var listenFD: Int32 = -1
private var listenSource: DispatchSourceRead?
private var clients: [Int32: ClientState] = [:]
private var router: MCPRouter!
private var database: BrainDatabase!
+ private var hybridSearchHelperClient: HybridSearchHelperClient?
+ private let providedHybridSearchClient: HybridSearchClientProtocol?
+ private let enableHybridSearchHelper: Bool
+ private let brainBus = BrainBusEventHub()
+ private var brainBusQueueDepthEstimate = 0
private var databaseRetryWorkItem: DispatchWorkItem?
private var lastDatabaseRetryDelayMillis: UInt64?
private var databaseOpenInProgress = false
@@ -105,8 +112,9 @@ final class BrainBarServer: @unchecked Sendable {
var onDatabaseReady: (@Sendable (BrainDatabase) -> Void)?
var onStartRejected: (@Sendable (String) -> Void)?
/// Maximum EAGAIN retries before disconnecting a stalled client.
- /// Each retry sleeps 1ms, so 10 retries = 10ms max blocking the serial queue.
- static let maxWriteRetries = 10
+ /// Each retry sleeps 1ms; 50 retries keeps false-positive disconnects
+ /// below the UI budget while still bounding serial-queue stalls.
+ static let maxWriteRetries = 50
private let debugLogPath = "/tmp/brainbar-debug.log"
private func debugLog(_ msg: String) {
@@ -135,20 +143,26 @@ final class BrainBarServer: @unchecked Sendable {
var usesContentLengthFraming: Bool = true
var agentID: String?
var subscribedTags: Set = []
+ var brainBusSubscriptionID: BrainBusEventHub.SubscriptionID?
}
init(
socketPath: String? = nil,
dbPath: String? = nil,
database: BrainDatabase? = nil,
+ hybridSearchClient: HybridSearchClientProtocol? = nil,
+ enableHybridSearchHelper: Bool = true,
databaseRecoveryPolicy: DatabaseRecoveryPolicy = DatabaseRecoveryPolicy(),
instanceLockPath: String? = nil
) {
self.socketPath = socketPath ?? Self.defaultSocketPath()
self.dbPath = dbPath ?? Self.defaultDBPath()
providedDatabase = database
+ providedHybridSearchClient = hybridSearchClient
+ self.enableHybridSearchHelper = enableHybridSearchHelper
self.databaseRecoveryPolicy = databaseRecoveryPolicy
self.instanceLockPath = instanceLockPath ?? Self.defaultInstanceLockPath(socketPath: self.socketPath)
+ queue.setSpecific(key: Self.queueKey, value: queueID)
}
static func defaultSocketPath() -> String {
@@ -197,9 +211,23 @@ final class BrainBarServer: @unchecked Sendable {
return
}
+ let ownedHybridClient: HybridSearchHelperClient?
+ let hybridClient: HybridSearchClientProtocol?
+ if let providedHybridSearchClient {
+ ownedHybridClient = nil
+ hybridClient = providedHybridSearchClient
+ } else if providedDatabase == nil && enableHybridSearchHelper {
+ let client = HybridSearchHelperClient(dbPath: dbPath)
+ ownedHybridClient = client
+ hybridClient = client
+ } else {
+ ownedHybridClient = nil
+ hybridClient = nil
+ }
+
// 1. Create router FIRST (no DB dependency).
// initialize + tools/list work without a database.
- router = MCPRouter()
+ router = MCPRouter(hybridSearchClient: hybridClient)
// 2. Bind socket BEFORE database init.
// After a restart the socket must exist before Claude Code tries
@@ -267,6 +295,15 @@ final class BrainBarServer: @unchecked Sendable {
NSLog("[BrainBar] Server listening on %@", socketPath)
debugLog("SERVER STARTED — listening on \(socketPath)")
+ if let ownedHybridClient {
+ hybridSearchHelperClient = ownedHybridClient
+ do {
+ try ownedHybridClient.start()
+ } catch {
+ NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error))
+ }
+ }
+
// 3. NOW open the database (may take time on cold start with 8 GB file).
// Connections accepted above queue in the listen backlog.
// initialize / tools/list already work; tools/call returns a
@@ -312,6 +349,9 @@ final class BrainBarServer: @unchecked Sendable {
database = db
router.setDatabase(db)
onDatabaseReady?(db)
+ brainBus.publish(.dbBusy(false))
+ brainBus.publish(.queueDepth(brainBusQueueDepthEstimate))
+ brainBus.publish(.enrichStatus("idle"))
NSLog("[BrainBar] Database ready (%@)", dbPath)
return
}
@@ -320,6 +360,7 @@ final class BrainBarServer: @unchecked Sendable {
db.close()
}
NSLog("[BrainBar] ⚠️ DATABASE FAILED TO OPEN — tools/call will return errors (%@)", dbPath)
+ brainBus.publish(.dbBusy(true))
scheduleDatabaseRetry(lastError: db.lastOpenError)
}
@@ -391,6 +432,7 @@ final class BrainBarServer: @unchecked Sendable {
if !messages.isEmpty {
state.usesContentLengthFraming = state.framing.lastExtractUsedContentLength
}
+ clients[fd] = state
debugLog("EXTRACTED \(messages.count) messages from fd=\(fd) (framing=\(state.usesContentLengthFraming ? "content-length" : "newline-json"), buffer remaining: \(state.framing.bufferCount) bytes)")
for msg in messages {
let method = msg["method"] as? String ?? ""
@@ -428,11 +470,16 @@ final class BrainBarServer: @unchecked Sendable {
if toolCall.name == "brain_store", !isToolError(response) {
publishStoredChunks(response: response, arguments: toolCall.arguments)
}
+ if toolCall.name == "brain_enrich", !isToolError(response) {
+ brainBus.publish(.enrichStatus("running"))
+ }
return response
}
}
if let method = request["method"] as? String {
switch method {
+ case "watch-brain-bus":
+ return handleWatchBrainBus(fd: fd)
default:
break
}
@@ -485,6 +532,85 @@ final class BrainBarServer: @unchecked Sendable {
}
}
+ private func writeFramedData(fd: Int32, data: Data) -> Bool {
+ data.withUnsafeBytes { ptr in
+ var totalWritten = 0
+ var eagainRetries = 0
+ while totalWritten < data.count {
+ let n = write(fd, ptr.baseAddress!.advanced(by: totalWritten), data.count - totalWritten)
+ if n < 0 {
+ if errno == EAGAIN || errno == EWOULDBLOCK {
+ eagainRetries += 1
+ if eagainRetries > Self.maxWriteRetries {
+ return false
+ }
+ usleep(1000)
+ continue
+ }
+ return false
+ }
+ if n == 0 {
+ return false
+ }
+ totalWritten += n
+ eagainRetries = 0
+ }
+ return true
+ }
+ }
+
+ private func sendBrainBusFrame(fd: Int32, data: Data) -> Bool {
+ if DispatchQueue.getSpecific(key: Self.queueKey) == queueID {
+ guard clients[fd] != nil else { return false }
+ return writeFramedData(fd: fd, data: data)
+ }
+ return queue.sync {
+ guard clients[fd] != nil else { return false }
+ return writeFramedData(fd: fd, data: data)
+ }
+ }
+
+ private func handleWatchBrainBus(fd: Int32) -> [String: Any] {
+ guard var client = clients[fd] else { return [:] }
+ if let existing = client.brainBusSubscriptionID {
+ brainBus.unsubscribeSynchronously(existing)
+ }
+
+ let useContentLength = client.usesContentLengthFraming
+ let subscription = brainBus.subscribe { [weak self] event in
+ guard let self else { return false }
+ guard let response = self.brainBusNotification(event: event) else { return true }
+ let framed: Data
+ do {
+ if useContentLength {
+ framed = try MCPFraming.encode(response)
+ } else {
+ var data = try MCPFraming.encodeJSONResponse(response)
+ data.append(0x0A)
+ framed = data
+ }
+ } catch {
+ return true
+ }
+
+ let delivered = self.sendBrainBusFrame(fd: fd, data: framed)
+ if !delivered {
+ self.queue.async { [weak self] in
+ self?.disconnectClient(fd: fd)
+ }
+ }
+ return delivered
+ }
+
+ client.brainBusSubscriptionID = subscription
+ clients[fd] = client
+ brainBus.publish(.dbBusy(database == nil))
+ brainBus.publish(.queueDepth(brainBusQueueDepthEstimate))
+ brainBus.publish(.enrichStatus("idle"))
+ brainBus.publish(.healthTick(openConnections: clients.count))
+ return [:]
+ }
+
private static let claudeExtensionRawChunkLimit = 8192
private static func encodeRawJSONResponse(_ response: [String: Any]) -> Data? {
@@ -519,6 +645,9 @@ final class BrainBarServer: @unchecked Sendable {
}
private func disconnectClient(fd: Int32) {
+ if let subscriptionID = clients[fd]?.brainBusSubscriptionID {
+ brainBus.unsubscribeSynchronously(subscriptionID)
+ }
if let agentID = clients[fd]?.agentID {
try? database?.markSubscriberDisconnected(agentID: agentID)
}
@@ -533,6 +662,9 @@ final class BrainBarServer: @unchecked Sendable {
listenSource?.cancel()
listenSource = nil
for (_, state) in clients {
+ if let subscriptionID = state.brainBusSubscriptionID {
+ brainBus.unsubscribeSynchronously(subscriptionID)
+ }
state.source.cancel()
}
clients.removeAll()
@@ -542,6 +674,8 @@ final class BrainBarServer: @unchecked Sendable {
database?.close()
}
database = nil
+ hybridSearchHelperClient?.stop()
+ hybridSearchHelperClient = nil
databaseOpenInProgress = false
instanceLock?.release()
instanceLock = nil
@@ -733,6 +867,9 @@ final class BrainBarServer: @unchecked Sendable {
}
private func publishStoredChunk(stored: StoreResultPayload, content: String, tags: [String], importance: Int) {
+ brainBusQueueDepthEstimate += 1
+ brainBus.publish(.queueDepth(brainBusQueueDepthEstimate))
+ brainBus.publish(.lastChunkID(stored.chunkID))
guard !tags.isEmpty else { return }
let tagSet = Set(tags)
for (clientFD, client) in Array(clients) {
@@ -830,4 +967,13 @@ final class BrainBarServer: @unchecked Sendable {
}
return object
}
+
+ private func brainBusNotification(event: BrainBusEvent) -> [String: Any]? {
+ guard let params = jsonObject(event) else { return nil }
+ return [
+ "jsonrpc": "2.0",
+ "method": "notifications/brain-bus",
+ "params": params
+ ]
+ }
}
diff --git a/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift
new file mode 100644
index 00000000..7a13da72
--- /dev/null
+++ b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift
@@ -0,0 +1,104 @@
+import AppKit
+import Combine
+import SwiftUI
+
+@MainActor
+final class BrainBarStatusPopoverController: NSObject {
+ static let contentSize = NSSize(width: 900, height: 640)
+
+ let statusItemForTesting: NSStatusItem
+ let popoverForTesting: NSPopover
+
+ private let runtime: BrainBarRuntime
+ private var runtimeCancellables: Set = []
+ private var collectorCancellables: Set = []
+
+ init(runtime: BrainBarRuntime) {
+ self.runtime = runtime
+ statusItemForTesting = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ popoverForTesting = NSPopover()
+ super.init()
+
+ configureStatusItem()
+ prewarmPopover()
+ bindRuntime()
+ }
+
+ func toggle(_ sender: Any?) {
+ if popoverForTesting.isShown {
+ popoverForTesting.performClose(sender)
+ } else {
+ show(sender)
+ }
+ }
+
+ func show(_ sender: Any?) {
+ guard let button = statusItemForTesting.button else { return }
+ popoverForTesting.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+ popoverForTesting.contentViewController?.view.window?.makeKey()
+ }
+
+ func close(_ sender: Any?) {
+ popoverForTesting.performClose(sender)
+ }
+
+ func stop() {
+ close(nil)
+ NSStatusBar.system.removeStatusItem(statusItemForTesting)
+ }
+
+ private func configureStatusItem() {
+ guard let button = statusItemForTesting.button else { return }
+ button.image = NSImage(systemSymbolName: "brain", accessibilityDescription: "BrainBar")
+ button.target = self
+ button.action = #selector(toggleFromStatusItem(_:))
+ button.toolTip = "BrainBar"
+ }
+
+ private func prewarmPopover() {
+ popoverForTesting.behavior = .transient
+ popoverForTesting.contentSize = Self.contentSize
+
+ let hosting = NSHostingController(
+ rootView: BrainBarWindowRootView(runtime: runtime, managesWindowFrame: false)
+ .frame(width: Self.contentSize.width, height: Self.contentSize.height)
+ )
+ _ = hosting.view
+ popoverForTesting.contentViewController = hosting
+ }
+
+ private func bindRuntime() {
+ runtime.$collector
+ .receive(on: RunLoop.main)
+ .sink { [weak self] collector in
+ self?.bindCollector(collector)
+ }
+ .store(in: &runtimeCancellables)
+ }
+
+ private func bindCollector(_ collector: StatsCollector?) {
+ collectorCancellables.removeAll()
+ guard let collector else { return }
+
+ Publishers.CombineLatest(collector.$stats, collector.$state)
+ .receive(on: RunLoop.main)
+ .sink { [weak self] stats, state in
+ self?.renderStatusIcon(stats: stats, state: state)
+ }
+ .store(in: &collectorCancellables)
+ }
+
+ private func renderStatusIcon(stats: BrainDatabase.DashboardStats, state: PipelineState) {
+ let livePresentation = BrainBarLivePresentation.derive(stats: stats)
+ statusItemForTesting.button?.image = SparklineRenderer.render(
+ state: state,
+ values: stats.recentEnrichmentBuckets,
+ size: NSSize(width: 22, height: 12),
+ accentColor: livePresentation.accentColor
+ )
+ }
+
+ @objc private func toggleFromStatusItem(_ sender: Any?) {
+ toggle(sender)
+ }
+}
diff --git a/brain-bar/Sources/BrainBar/BrainBusClient.swift b/brain-bar/Sources/BrainBar/BrainBusClient.swift
new file mode 100644
index 00000000..6896bebf
--- /dev/null
+++ b/brain-bar/Sources/BrainBar/BrainBusClient.swift
@@ -0,0 +1,170 @@
+import Darwin
+import Foundation
+
+protocol BrainBusEventSource: Sendable {
+ func events() -> AsyncStream
+}
+
+final class BrainBusClient: BrainBusEventSource, @unchecked Sendable {
+ private let socketPath: String
+ private let reconnectDelay: TimeInterval
+ private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus-client")
+
+ init(socketPath: String = BrainBarServer.defaultSocketPath(), reconnectDelay: TimeInterval = 1.0) {
+ self.socketPath = socketPath
+ self.reconnectDelay = reconnectDelay
+ }
+
+ func events() -> AsyncStream {
+ AsyncStream { continuation in
+ let run = BrainBusClientRun(
+ socketPath: socketPath,
+ reconnectDelay: reconnectDelay,
+ continuation: continuation
+ )
+ continuation.onTermination = { @Sendable _ in
+ run.cancel()
+ }
+ queue.async {
+ run.start()
+ }
+ }
+ }
+}
+
+private final class BrainBusClientRun: @unchecked Sendable {
+ private let socketPath: String
+ private let reconnectDelay: TimeInterval
+ private let continuation: AsyncStream.Continuation
+ private let lock = NSLock()
+ private var cancelled = false
+ private var currentFD: Int32 = -1
+
+ init(
+ socketPath: String,
+ reconnectDelay: TimeInterval,
+ continuation: AsyncStream.Continuation
+ ) {
+ self.socketPath = socketPath
+ self.reconnectDelay = reconnectDelay
+ self.continuation = continuation
+ }
+
+ func start() {
+ while !isCancelled {
+ autoreleasepool {
+ if let fd = try? connect() {
+ setCurrentFD(fd)
+ sendWatchCommand(fd: fd)
+ readEvents(fd: fd)
+ close(fd)
+ setCurrentFD(-1)
+ }
+ }
+ if !isCancelled {
+ Thread.sleep(forTimeInterval: reconnectDelay)
+ }
+ }
+ continuation.finish()
+ }
+
+ func cancel() {
+ let fd: Int32 = lock.withLock {
+ cancelled = true
+ return currentFD
+ }
+ if fd >= 0 {
+ shutdown(fd, SHUT_RDWR)
+ }
+ }
+
+ private var isCancelled: Bool {
+ lock.withLock { cancelled }
+ }
+
+ private func setCurrentFD(_ fd: Int32) {
+ lock.withLock {
+ currentFD = fd
+ }
+ }
+
+ private func connect() throws -> Int32 {
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else { throw BrainBusClientError.socket(errno) }
+
+ var addr = sockaddr_un()
+ addr.sun_family = sa_family_t(AF_UNIX)
+ let pathBytes = socketPath.utf8CString
+ guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else {
+ close(fd)
+ throw BrainBusClientError.socketPathTooLong(socketPath)
+ }
+ withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
+ ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in
+ pathBytes.withUnsafeBufferPointer { src in
+ _ = memcpy(dest, src.baseAddress!, src.count)
+ }
+ }
+ }
+
+ let result = withUnsafePointer(to: &addr) { addrPtr in
+ addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in
+ Darwin.connect(fd, ptr, socklen_t(MemoryLayout.size))
+ }
+ }
+ guard result == 0 else {
+ let code = errno
+ close(fd)
+ throw BrainBusClientError.connect(code)
+ }
+ return fd
+ }
+
+ private func sendWatchCommand(fd: Int32) {
+ let request: [String: Any] = [
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "watch-brain-bus",
+ ]
+ guard var data = try? JSONSerialization.data(withJSONObject: request) else { return }
+ data.append(0x0A)
+ data.withUnsafeBytes { ptr in
+ _ = write(fd, ptr.baseAddress!, data.count)
+ }
+ }
+
+ private func readEvents(fd: Int32) {
+ var buffer = Data()
+ var readBuffer = [UInt8](repeating: 0, count: 8192)
+ while !isCancelled {
+ let count = read(fd, &readBuffer, readBuffer.count)
+ if count > 0 {
+ buffer.append(contentsOf: readBuffer[0.. BrainBusEvent {
+ BrainBusEvent(type: .queueDepth, queueDepth: depth)
+ }
+
+ static func enrichStatus(_ status: String) -> BrainBusEvent {
+ BrainBusEvent(type: .enrichStatus, enrichStatus: status)
+ }
+
+ static func lastChunkID(_ chunkID: String) -> BrainBusEvent {
+ BrainBusEvent(type: .lastChunkID, lastChunkID: chunkID)
+ }
+
+ static func dbBusy(_ busy: Bool) -> BrainBusEvent {
+ BrainBusEvent(type: .dbBusy, dbBusy: busy)
+ }
+
+ static func healthTick(openConnections: Int) -> BrainBusEvent {
+ BrainBusEvent(type: .healthTick, openConnections: openConnections)
+ }
+
+ func withSequence(_ sequence: Int, generatedAt: Date = Date()) -> BrainBusEvent {
+ BrainBusEvent(
+ type: type,
+ sequence: sequence,
+ generatedAt: generatedAt,
+ queueDepth: queueDepth,
+ enrichStatus: enrichStatus,
+ lastChunkID: lastChunkID,
+ dbBusy: dbBusy,
+ openConnections: openConnections
+ )
+ }
+
+ private init(
+ type: BrainBusEventType,
+ sequence: Int = 0,
+ generatedAt: Date = Date(),
+ queueDepth: Int? = nil,
+ enrichStatus: String? = nil,
+ lastChunkID: String? = nil,
+ dbBusy: Bool? = nil,
+ openConnections: Int? = nil
+ ) {
+ self.type = type
+ self.sequence = sequence
+ self.generatedAt = generatedAt
+ self.queueDepth = queueDepth
+ self.enrichStatus = enrichStatus
+ self.lastChunkID = lastChunkID
+ self.dbBusy = dbBusy
+ self.openConnections = openConnections
+ }
+}
+
+final class BrainBusEventHub: @unchecked Sendable {
+ typealias SubscriptionID = UUID
+ typealias EventWriter = @Sendable (BrainBusEvent) -> Bool
+
+ private struct Subscriber {
+ let writer: EventWriter
+ let drainQueue: DispatchQueue
+ var buffer: [BrainBusEvent] = []
+ var isDraining = false
+ }
+
+ private static let queueKey = DispatchSpecificKey()
+ private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus")
+ private let queueID = UUID()
+ private let bufferCapacity: Int
+ private var nextSequence = 0
+ private var subscribers: [SubscriptionID: Subscriber] = [:]
+ private var heartbeatTimer: DispatchSourceTimer?
+
+ init(bufferCapacity: Int = 64, heartbeatInterval: TimeInterval? = 5) {
+ self.bufferCapacity = max(1, bufferCapacity)
+ queue.setSpecific(key: Self.queueKey, value: queueID)
+ if let heartbeatInterval {
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval, leeway: .milliseconds(100))
+ timer.setEventHandler { [weak self] in
+ self?.publishOnQueue(.healthTick(openConnections: self?.subscribers.count ?? 0))
+ }
+ timer.resume()
+ heartbeatTimer = timer
+ }
+ }
+
+ deinit {
+ heartbeatTimer?.cancel()
+ }
+
+ func subscribe(_ writer: @escaping EventWriter) -> SubscriptionID {
+ let id = UUID()
+ queue.sync {
+ subscribers[id] = Subscriber(
+ writer: writer,
+ drainQueue: DispatchQueue(label: "com.brainlayer.brainbar.brain-bus.subscriber.\(id.uuidString)")
+ )
+ }
+ return id
+ }
+
+ func unsubscribe(_ id: SubscriptionID) {
+ queue.async {
+ self.subscribers.removeValue(forKey: id)
+ }
+ }
+
+ func unsubscribeSynchronously(_ id: SubscriptionID) {
+ if DispatchQueue.getSpecific(key: Self.queueKey) == queueID {
+ subscribers.removeValue(forKey: id)
+ return
+ }
+ _ = queue.sync {
+ self.subscribers.removeValue(forKey: id)
+ }
+ }
+
+ func publish(_ event: BrainBusEvent) {
+ queue.async {
+ self.publishOnQueue(event)
+ }
+ }
+
+ private func publishOnQueue(_ event: BrainBusEvent) {
+ nextSequence += 1
+ let sequenced = event.withSequence(nextSequence)
+ for id in subscribers.keys {
+ enqueue(sequenced, for: id)
+ }
+ }
+
+ private func enqueue(_ event: BrainBusEvent, for id: SubscriptionID) {
+ guard var subscriber = subscribers[id] else { return }
+ if subscriber.buffer.count >= bufferCapacity {
+ subscriber.buffer.removeFirst()
+ }
+ subscriber.buffer.append(event)
+ let shouldStartDrain = !subscriber.isDraining
+ if shouldStartDrain {
+ subscriber.isDraining = true
+ }
+ subscribers[id] = subscriber
+ if shouldStartDrain {
+ drainNext(for: id)
+ }
+ }
+
+ private func drainNext(for id: SubscriptionID) {
+ guard var subscriber = subscribers[id] else { return }
+ guard !subscriber.buffer.isEmpty else {
+ subscriber.isDraining = false
+ subscribers[id] = subscriber
+ return
+ }
+ let event = subscriber.buffer.removeFirst()
+ let writer = subscriber.writer
+ let drainQueue = subscriber.drainQueue
+ subscribers[id] = subscriber
+
+ drainQueue.async { [weak self] in
+ let keepOpen = writer(event)
+ self?.queue.async { [weak self] in
+ guard keepOpen else {
+ self?.subscribers.removeValue(forKey: id)
+ return
+ }
+ self?.drainNext(for: id)
+ }
+ }
+ }
+}
diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift
index 22e8582d..c4ba4c43 100644
--- a/brain-bar/Sources/BrainBar/BrainDatabase.swift
+++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift
@@ -10,9 +10,11 @@ import SQLite3
final class BrainDatabase: @unchecked Sendable {
struct OpenConfiguration: Sendable, Equatable {
let busyTimeoutMillis: Int32
+ let readOnly: Bool
- init(busyTimeoutMillis: Int32 = 30_000) {
+ init(busyTimeoutMillis: Int32 = 30_000, readOnly: Bool = false) {
self.busyTimeoutMillis = max(1, busyTimeoutMillis)
+ self.readOnly = readOnly
}
}
@@ -255,6 +257,12 @@ final class BrainDatabase: @unchecked Sendable {
NSLog("[BrainBar] Connection opened, configuring...")
try configureConnection(db)
NSLog("[BrainBar] Connection configured, checking schema...")
+ if openConfiguration.readOnly {
+ NSLog("[BrainBar] Read-only connection - skipping schema migration checks")
+ lastOpenError = nil
+ isOpen = true
+ return
+ }
// AIDEV-NOTE: Skip ensureSchema if chunks table already exists (Python creates all tables).
// CREATE TABLE IF NOT EXISTS still acquires a RESERVED lock which blocks on watch agent.
if (try? tableExists("chunks")) == true {
@@ -268,11 +276,21 @@ final class BrainDatabase: @unchecked Sendable {
isOpen = true
NSLog("[BrainBar] Database ready")
} catch {
+ if let db {
+ sqlite3_close(db)
+ self.db = nil
+ }
+ isOpen = false
lastOpenError = error
NSLog("[BrainBar] Failed to open/configure database at %@: %@", path, String(describing: error))
}
}
+ func reopenIfNeeded() {
+ guard !isOpen else { return }
+ openAndConfigure()
+ }
+
// tableExists defined at line ~237 (existing method)
private func ensureSchema() throws {
@@ -467,6 +485,7 @@ final class BrainDatabase: @unchecked Sendable {
sqlite3_close(db)
self.db = nil
}
+ isOpen = false
}
func vacuumInto(targetPath: String) throws -> Int64 {
@@ -1662,12 +1681,12 @@ final class BrainDatabase: @unchecked Sendable {
private func openConnection(path: String) throws -> OpaquePointer {
var handle: OpaquePointer?
- // AIDEV-NOTE: READWRITE is needed for save/search. The async init in BrainBarApp.swift
- // ensures this runs on a background queue so it doesn't block the menu item.
- let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
+ let flags = openConfiguration.readOnly
+ ? SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX
+ : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
let rc = sqlite3_open_v2(path, &handle, flags, nil)
guard rc == SQLITE_OK, let handle else { throw DBError.open(path, rc) }
- NSLog("[BrainBar] Database opened READWRITE")
+ NSLog("[BrainBar] Database opened %@", openConfiguration.readOnly ? "READONLY" : "READWRITE")
return handle
}
@@ -1675,6 +1694,12 @@ final class BrainDatabase: @unchecked Sendable {
guard let handle else { throw DBError.notOpen }
// AIDEV-NOTE: busy_timeout FIRST — 30s because watch agent holds locks during enrichment.
try executeOnHandle(handle, sql: "PRAGMA busy_timeout = \(openConfiguration.busyTimeoutMillis)")
+ if openConfiguration.readOnly {
+ try executeOnHandle(handle, sql: "PRAGMA query_only = ON")
+ try executeOnHandle(handle, sql: "PRAGMA cache_size = -64000")
+ try executeOnHandle(handle, sql: "PRAGMA foreign_keys = ON")
+ return
+ }
// AIDEV-NOTE: Skip journal_mode=WAL if already set — the PRAGMA itself needs a write lock
// which blocks indefinitely when the watch agent is active. WAL is already set by Python.
let currentMode = queryPragma(handle, name: "journal_mode")
@@ -1967,9 +1992,14 @@ final class BrainDatabase: @unchecked Sendable {
try execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
- applied_at TEXT NOT NULL
+ applied_at TEXT NOT NULL,
+ details TEXT
)
""")
+ let schemaMigrationColumns = try tableColumns(name: "schema_migrations", on: db)
+ if !schemaMigrationColumns.contains("details") {
+ try execute("ALTER TABLE schema_migrations ADD COLUMN details TEXT")
+ }
let existingColumns = try tableColumns(name: "chunks", on: db)
let atomicColumns = ["brick_id", "source_uri", "status", "ingested_at", "topic_cluster"]
let atomicMigrationApplied = try schemaMigrationApplied(name: "atomic_brick_chunks_v1", on: db)
diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift
index 0991010b..4ccef484 100644
--- a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift
+++ b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift
@@ -29,18 +29,22 @@ final class StatsCollector: ObservableObject {
private let database: BrainDatabase
private let daemonMonitor: DaemonHealthMonitor
private let agentActivityMonitor: AgentActivityMonitor
- private var pollTask: Task?
+ private let brainBusEvents: BrainBusEventSource?
+ private var brainBusTask: Task?
private var isRunning = false
private var lastDataVersion: Int?
init(
dbPath: String,
daemonMonitor: DaemonHealthMonitor,
- agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor()
+ agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor(),
+ brainBusEvents: BrainBusEventSource? = nil,
+ databaseOpenConfiguration: BrainDatabase.OpenConfiguration = BrainDatabase.OpenConfiguration()
) {
- self.database = BrainDatabase(path: dbPath)
+ self.database = BrainDatabase(path: dbPath, openConfiguration: databaseOpenConfiguration)
self.daemonMonitor = daemonMonitor
self.agentActivityMonitor = agentActivityMonitor
+ self.brainBusEvents = brainBusEvents
self.stats = DashboardStats(
chunkCount: 0,
enrichedChunkCount: 0,
@@ -62,18 +66,22 @@ final class StatsCollector: ObservableObject {
isRunning = true
installDarwinObserver()
refresh(force: true)
- pollTask = Task { [weak self] in
- while let self, !Task.isCancelled {
- try? await Task.sleep(for: .seconds(1))
- guard !Task.isCancelled else { break }
- self.pollForChanges()
+ if let brainBusEvents {
+ let eventStream = brainBusEvents.events()
+ brainBusTask = Task { [weak self] in
+ for await event in eventStream {
+ guard !Task.isCancelled else { break }
+ await MainActor.run {
+ self?.handleBrainBusEvent(event)
+ }
+ }
}
}
}
func stop() {
- pollTask?.cancel()
- pollTask = nil
+ brainBusTask?.cancel()
+ brainBusTask = nil
if isRunning {
removeDarwinObserver()
}
@@ -86,6 +94,7 @@ final class StatsCollector: ObservableObject {
agentActivity = agentActivityMonitor.sample()
do {
+ database.reopenIfNeeded()
let currentDataVersion = try database.dataVersion()
if force || currentDataVersion != lastDataVersion {
stats = try database.dashboardStats(
@@ -120,8 +129,15 @@ final class StatsCollector: ObservableObject {
refresh(force: false)
}
- private func pollForChanges() {
- refresh(force: false)
+ private func handleBrainBusEvent(_ event: BrainBusEvent) {
+ switch event.type {
+ case .healthTick:
+ daemon = daemonMonitor.sample()
+ agentActivity = agentActivityMonitor.sample()
+ state = PipelineState.derive(daemon: daemon, stats: stats)
+ case .queueDepth, .enrichStatus, .lastChunkID, .dbBusy:
+ refresh(force: false)
+ }
}
private func installDarwinObserver() {
diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift
new file mode 100644
index 00000000..770aee75
--- /dev/null
+++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift
@@ -0,0 +1,421 @@
+import Foundation
+
+struct HybridSearchResponse {
+ let text: String
+ let metadata: [String: Any]
+
+ init(text: String, metadata: [String: Any] = [:]) {
+ self.text = text
+ self.metadata = metadata
+ }
+}
+
+protocol HybridSearchClientProtocol: AnyObject, Sendable {
+ func search(arguments: [String: Any]) throws -> HybridSearchResponse
+}
+
+final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable {
+ private static let maxResponseBytes = 10 * 1024 * 1024
+ private static let defaultSocketIOTimeout: TimeInterval = 60
+ private static let queueKey = DispatchSpecificKey()
+ private let socketPath: String
+ private let dbPath: String
+ private let pythonExecutable: String
+ private let environment: [String: String]
+ private let socketIOTimeout: TimeInterval
+ private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper")
+ private let queueID = UUID()
+ private var process: Process?
+
+ init(
+ socketPath: String? = nil,
+ dbPath: String,
+ pythonExecutable: String? = nil,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ socketIOTimeout: TimeInterval = defaultSocketIOTimeout
+ ) {
+ self.socketPath = socketPath ?? Self.defaultSocketPath()
+ self.dbPath = dbPath
+ self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment)
+ self.environment = environment
+ self.socketIOTimeout = socketIOTimeout
+ queue.setSpecific(key: Self.queueKey, value: queueID)
+ }
+
+ deinit {
+ stop()
+ }
+
+ static func defaultSocketPath() -> String {
+ "/tmp/brainbar-hybrid-\(ProcessInfo.processInfo.processIdentifier).sock"
+ }
+
+ static func resolvePythonExecutable(environment: [String: String]) -> String {
+ if let explicit = environment["BRAINBAR_PYTHON"], !explicit.isEmpty {
+ return explicit
+ }
+ if let virtualEnv = environment["VIRTUAL_ENV"], !virtualEnv.isEmpty {
+ let candidate = "\(virtualEnv)/bin/python"
+ if FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+ }
+ if let repoRoot = normalizedRepoRoot(environment: environment) {
+ let candidate = "\(repoRoot)/.venv/bin/python"
+ if FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+ }
+ if let python3 = findExecutable(named: "python3", path: environment["PATH"]) {
+ return python3
+ }
+ if let python = findExecutable(named: "python", path: environment["PATH"]) {
+ return python
+ }
+ return "/usr/bin/env"
+ }
+
+ static func resolvePythonPath(environment: [String: String]) -> String? {
+ if let existing = environment["PYTHONPATH"], !existing.isEmpty {
+ return existing
+ }
+ guard let repoRoot = normalizedRepoRoot(environment: environment) else {
+ return nil
+ }
+ let sourcePath = "\(repoRoot)/src"
+ guard FileManager.default.fileExists(atPath: sourcePath) else {
+ return nil
+ }
+ return sourcePath
+ }
+
+ private static func normalizedRepoRoot(environment: [String: String]) -> String? {
+ guard let raw = environment["BRAINLAYER_REPO_ROOT"],
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ return URL(fileURLWithPath: raw).standardizedFileURL.path
+ }
+
+ private static func findExecutable(named name: String, path: String?) -> String? {
+ let searchPath = path?.split(separator: ":").map(String.init) ?? []
+ for dir in searchPath where !dir.isEmpty {
+ let candidate = "\(dir)/\(name)"
+ if FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+ }
+ return nil
+ }
+
+ var isRunning: Bool {
+ if DispatchQueue.getSpecific(key: Self.queueKey) == queueID {
+ return process?.isRunning == true
+ }
+ return queue.sync {
+ process?.isRunning == true
+ }
+ }
+
+ func start() throws {
+ try queue.sync {
+ try startLocked()
+ }
+ }
+
+ func stop() {
+ if DispatchQueue.getSpecific(key: Self.queueKey) == queueID {
+ stopLocked()
+ return
+ }
+ queue.sync {
+ stopLocked()
+ }
+ }
+
+ func search(arguments: [String: Any]) throws -> HybridSearchResponse {
+ try queue.sync {
+ try startLocked()
+ do {
+ return try send(arguments: arguments)
+ } catch {
+ if Self.shouldRestartHelper(after: error) {
+ stopLocked()
+ }
+ throw error
+ }
+ }
+ }
+
+ private func stopLocked() {
+ if let process, process.isRunning {
+ process.terminate()
+ process.waitUntilExit()
+ }
+ process = nil
+ unlink(socketPath)
+ }
+
+ private func startLocked() throws {
+ if let process, process.isRunning {
+ return
+ }
+
+ try Self.validateSocketPath(socketPath)
+ unlink(socketPath)
+
+ let proc = Process()
+ if pythonExecutable == "/usr/bin/env" {
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ proc.arguments = [
+ "python3",
+ "-m",
+ "brainlayer.brainbar_hybrid_helper",
+ "--socket-path",
+ socketPath,
+ "--db-path",
+ dbPath
+ ]
+ } else {
+ proc.executableURL = URL(fileURLWithPath: pythonExecutable)
+ proc.arguments = [
+ "-m",
+ "brainlayer.brainbar_hybrid_helper",
+ "--socket-path",
+ socketPath,
+ "--db-path",
+ dbPath
+ ]
+ }
+
+ var env = environment
+ env["BRAINLAYER_DB"] = dbPath
+ if let pythonPath = Self.resolvePythonPath(environment: env) {
+ env["PYTHONPATH"] = pythonPath
+ }
+ env["PYTHONUNBUFFERED"] = "1"
+ proc.environment = env
+ proc.standardInput = Pipe()
+ proc.standardOutput = FileHandle.nullDevice
+ proc.standardError = FileHandle.standardError
+
+ do {
+ try proc.run()
+ process = proc
+ NSLog("[BrainBar] Hybrid search helper started pid=%d socket=%@", proc.processIdentifier, socketPath)
+ } catch {
+ NSLog("[BrainBar] Failed to start hybrid search helper: %@", String(describing: error))
+ process = nil
+ throw HybridSearchHelperError.launch(String(describing: error))
+ }
+ }
+
+ private func send(arguments: [String: Any]) throws -> HybridSearchResponse {
+ let fd = try connectWithRetry()
+ defer { close(fd) }
+
+ let payload: [String: Any] = [
+ "method": "brain_search",
+ "arguments": arguments
+ ]
+ let data = try JSONSerialization.data(withJSONObject: payload)
+ var framed = data
+ framed.append(0x0A)
+ try writeAll(fd: fd, data: framed)
+ let responseData = try readLine(fd: fd)
+ let decoded = try JSONSerialization.jsonObject(with: responseData) as? [String: Any]
+ guard let decoded else {
+ throw HybridSearchHelperError.invalidResponse
+ }
+ if let ok = decoded["ok"] as? Bool, !ok {
+ let message = decoded["error"] as? String ?? "unknown helper error"
+ throw HybridSearchHelperError.helperError(message)
+ }
+ guard let text = decoded["text"] as? String else {
+ throw HybridSearchHelperError.invalidResponse
+ }
+ let metadata = decoded["metadata"] as? [String: Any] ?? [:]
+ return HybridSearchResponse(text: text, metadata: metadata)
+ }
+
+ private func connectWithRetry() throws -> Int32 {
+ var lastErrno: Int32 = 0
+ for attempt in 0..<50 {
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
+ guard fd >= 0 else {
+ throw HybridSearchHelperError.socket(errno)
+ }
+ do {
+ try Self.configureNoSigpipe(fd: fd)
+ } catch {
+ close(fd)
+ throw error
+ }
+
+ var addr = sockaddr_un()
+ addr.sun_family = sa_family_t(AF_UNIX)
+ let pathBytes = try Self.validateSocketPath(socketPath)
+ withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
+ ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in
+ pathBytes.withUnsafeBufferPointer { src in
+ _ = memcpy(dest, src.baseAddress!, src.count)
+ }
+ }
+ }
+
+ let rc = withUnsafePointer(to: &addr) { addrPtr in
+ addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in
+ connect(fd, ptr, socklen_t(MemoryLayout.size))
+ }
+ }
+ if rc == 0 {
+ do {
+ try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout)
+ return fd
+ } catch {
+ close(fd)
+ throw error
+ }
+ }
+ lastErrno = errno
+ close(fd)
+ usleep(useconds_t(min(50_000 + attempt * 10_000, 250_000)))
+ }
+ throw HybridSearchHelperError.connect(lastErrno)
+ }
+
+ @discardableResult
+ static func validateSocketPath(_ path: String) throws -> ContiguousArray {
+ let pathBytes = path.utf8CString
+ let addr = sockaddr_un()
+ guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else {
+ throw HybridSearchHelperError.socketPathTooLong(path)
+ }
+ return ContiguousArray(pathBytes)
+ }
+
+ static func configureNoSigpipe(fd: Int32) throws {
+ var nosigpipe: Int32 = 1
+ if setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout.size)) != 0 {
+ throw HybridSearchHelperError.configureSocket(errno)
+ }
+ }
+
+ static func configureSocketTimeouts(fd: Int32, timeout: TimeInterval) throws {
+ let boundedTimeout = max(timeout, 0.001)
+ let seconds = Int(boundedTimeout)
+ let microseconds = Int((boundedTimeout - Double(seconds)) * 1_000_000)
+ var timeval = timeval(tv_sec: seconds, tv_usec: Int32(microseconds))
+ let size = socklen_t(MemoryLayout.size)
+
+ if setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeval, size) != 0 {
+ throw HybridSearchHelperError.configureSocket(errno)
+ }
+ if setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeval, size) != 0 {
+ throw HybridSearchHelperError.configureSocket(errno)
+ }
+ }
+
+ private static func shouldRestartHelper(after error: Error) -> Bool {
+ guard let helperError = error as? HybridSearchHelperError else {
+ return false
+ }
+ switch helperError {
+ case .connect, .configureSocket, .write, .read, .responseTooLarge, .invalidResponse:
+ return true
+ case .socket, .socketPathTooLong, .launch, .helperError:
+ return false
+ }
+ }
+
+ private func writeAll(fd: Int32, data: Data) throws {
+ try data.withUnsafeBytes { rawBuffer in
+ guard let base = rawBuffer.baseAddress else { return }
+ var written = 0
+ while written < data.count {
+ let count = write(fd, base.advanced(by: written), data.count - written)
+ if count < 0 {
+ if errno == EINTR { continue }
+ throw HybridSearchHelperError.write(errno)
+ }
+ if count == 0 {
+ throw HybridSearchHelperError.write(EPIPE)
+ }
+ written += count
+ }
+ }
+ }
+
+ private func readLine(fd: Int32) throws -> Data {
+ var result = Data()
+ let bufferSize = 8192
+ var buffer = [UInt8](repeating: 0, count: bufferSize)
+ while true {
+ let count = buffer.withUnsafeMutableBytes { rawBuffer in
+ read(fd, rawBuffer.baseAddress, bufferSize)
+ }
+ if count < 0 {
+ if errno == EINTR { continue }
+ throw HybridSearchHelperError.read(errno)
+ }
+ if count == 0 {
+ break
+ }
+
+ let chunk = buffer[.. 0 {
+ if result.count + endIndex > Self.maxResponseBytes {
+ throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes)
+ }
+ result.append(contentsOf: buffer[.. String {
+ let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil
+ if hasActiveFilters {
+ return ""
+ }
let detected = entityCache.detectEntities(in: query)
- if let first = detected.first {
- let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? []
- if !facts.isEmpty {
- kgSection = TextFormatter.formatKGFacts(entity: first.name, facts: facts)
- }
+ guard let first = detected.first else {
+ return ""
}
+ let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? []
+ if facts.isEmpty {
+ return ""
+ }
+ return TextFormatter.formatKGFacts(entity: first.name, facts: facts)
}
- let results = try db.search(
- query: query,
- limit: limit,
- project: project,
- source: source,
- tag: tag,
- importanceMin: importanceMin,
- subscriberID: subscriberID,
- unreadOnly: unreadOnly
- )
- let typedResults = results.map(SearchResult.init(payload:))
- let textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count)
+ func searchViaBrainBarDatabase() throws -> (text: String, metadata: [String: Any]) {
+ let results = try db.search(
+ query: query,
+ limit: limit,
+ project: project,
+ source: source,
+ tag: tag,
+ importanceMin: importanceMin,
+ subscriberID: subscriberID,
+ unreadOnly: unreadOnly
+ )
+ let typedResults = results.map(SearchResult.init(payload:))
+ return (
+ TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count),
+ [:]
+ )
+ }
+
+ let textSection: String
+ let metadata: [String: Any]
+ let kgSection: String
+ if let hybridSearchClient, subscriberID == nil, !unreadOnly {
+ do {
+ let response = try hybridSearchClient.search(arguments: hybridSearchArguments(
+ query: query,
+ limit: limit,
+ project: project,
+ source: source,
+ tag: tag,
+ importanceMin: importanceMin,
+ detail: args["detail"] as? String
+ ))
+ textSection = response.text
+ metadata = sanitizedHybridMetadata(response.metadata)
+ kgSection = ""
+ } catch {
+ NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error))
+ let fallback = try searchViaBrainBarDatabase()
+ textSection = fallback.text
+ metadata = fallback.metadata
+ kgSection = localKGSection()
+ }
+ } else {
+ let fallback = try searchViaBrainBarDatabase()
+ textSection = fallback.text
+ metadata = fallback.metadata
+ kgSection = localKGSection()
+ }
// KG section goes before the envelope
if kgSection.isEmpty {
- return ToolOutput(text: textSection)
+ return ToolOutput(text: textSection, metadata: metadata)
+ }
+ return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection, metadata: metadata)
+ }
+
+ private func sanitizedHybridMetadata(_ metadata: [String: Any]) -> [String: Any] {
+ var allowed: [String: Any] = [:]
+ if let structuredContent = metadata["structuredContent"] {
+ allowed["structuredContent"] = structuredContent
+ }
+ return allowed
+ }
+
+ private func hybridSearchArguments(
+ query: String,
+ limit: Int,
+ project: String?,
+ source: String?,
+ tag: String?,
+ importanceMin: Double?,
+ detail: String?
+ ) -> [String: Any] {
+ var arguments: [String: Any] = [
+ "query": query,
+ "num_results": limit,
+ "source": source ?? "all",
+ "detail": detail ?? "compact"
+ ]
+ if let project {
+ arguments["project"] = project
+ }
+ if let tag {
+ arguments["tag"] = tag
+ }
+ if let importanceMin {
+ arguments["importance_min"] = importanceMin
}
- return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection)
+ return arguments
}
private func handleBrainStore(_ args: [String: Any]) throws -> ToolOutput {
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift
new file mode 120000
index 00000000..ffb642e3
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarCommandBar.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift
new file mode 100644
index 00000000..679611cb
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+@main
+enum BrainBarDaemonMain {
+ static func main() {
+ let server = BrainBarServer()
+ server.onStartRejected = { reason in
+ NSLog("[BrainBarDaemon] Startup rejected: %@", reason)
+ Foundation.exit(1)
+ }
+ server.onDatabaseReady = { _ in
+ NSLog("[BrainBarDaemon] Database ready")
+ }
+ server.start()
+ NSLog("[BrainBarDaemon] Started on %@", BrainBarServer.defaultSocketPath())
+ RunLoop.main.run()
+ }
+}
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift
new file mode 120000
index 00000000..d6a1b54f
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarDashboardPanelController.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift
new file mode 120000
index 00000000..9ee0500b
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarInstanceLock.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift
new file mode 120000
index 00000000..9f09c04f
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarRuntime.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift
new file mode 120000
index 00000000..049d137c
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarServer.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift
new file mode 120000
index 00000000..af4bdfbb
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarStatusPopoverController.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift
new file mode 120000
index 00000000..972de793
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarURLAction.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift
new file mode 120000
index 00000000..3ba246b7
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarWindowRootView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift
new file mode 120000
index 00000000..c7104bad
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBarWindowState.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift b/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift
new file mode 120000
index 00000000..178878fb
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBusClient.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift b/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift
new file mode 120000
index 00000000..e7e0a2eb
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift
@@ -0,0 +1 @@
+../BrainBar/BrainBusEvent.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift b/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift
new file mode 120000
index 00000000..04590daa
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift
@@ -0,0 +1 @@
+../BrainBar/BrainDatabase.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift
new file mode 120000
index 00000000..7b82ac34
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/AgentActivityMonitor.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift
new file mode 120000
index 00000000..8c3364a5
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/BrainBarLivePulse.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift
new file mode 120000
index 00000000..e4016c38
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/DaemonHealthMonitor.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift
new file mode 120000
index 00000000..70b250ff
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/DashboardMetricFormatter.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift
new file mode 120000
index 00000000..f42036fa
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/PipelineState.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift
new file mode 120000
index 00000000..2b0c227e
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/SparklineRenderer.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift
new file mode 120000
index 00000000..02f5bbb8
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/StatsCollector.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift
new file mode 120000
index 00000000..165246f4
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift
@@ -0,0 +1 @@
+../../BrainBar/Dashboard/StatusPopoverView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Formatters.swift b/brain-bar/Sources/BrainBarDaemon/Formatters.swift
new file mode 120000
index 00000000..22a91276
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Formatters.swift
@@ -0,0 +1 @@
+../BrainBar/Formatters.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift b/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift
new file mode 120000
index 00000000..88ae46ab
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift
@@ -0,0 +1 @@
+../../BrainBar/Formatting/TextFormatter.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift b/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift
new file mode 120000
index 00000000..f7f19134
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift
@@ -0,0 +1 @@
+../BrainBar/HotkeyManager.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift b/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift
new file mode 120000
index 00000000..f2521bd7
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift
@@ -0,0 +1 @@
+../BrainBar/HotkeyRouteStatus.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift
new file mode 120000
index 00000000..1a0ea872
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift
@@ -0,0 +1 @@
+../BrainBar/HybridSearchHelperClient.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift b/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift
new file mode 120000
index 00000000..4b1cfed8
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift
@@ -0,0 +1 @@
+../BrainBar/InjectionEvent.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift b/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift
new file mode 120000
index 00000000..17a00fa6
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift
@@ -0,0 +1 @@
+../BrainBar/InjectionFeedView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift b/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift
new file mode 120000
index 00000000..1c724ded
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift
@@ -0,0 +1 @@
+../BrainBar/InjectionPresentation.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift b/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift
new file mode 120000
index 00000000..4e0d2d3a
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift
@@ -0,0 +1 @@
+../BrainBar/InjectionStore.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift b/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift
new file mode 120000
index 00000000..e239e6eb
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift
@@ -0,0 +1 @@
+../BrainBar/InjectionSummaryView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift
new file mode 120000
index 00000000..49fba994
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/EntityCache.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift
new file mode 120000
index 00000000..a611831f
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGAtlasLayout.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift
new file mode 120000
index 00000000..795c7e2e
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGAtlasPresentation.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift
new file mode 120000
index 00000000..0864aa58
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGCanvasView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift
new file mode 120000
index 00000000..7dd70b01
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGEdge.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift
new file mode 120000
index 00000000..e9530be3
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGEdgeView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift
new file mode 120000
index 00000000..831adac6
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGNode.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift
new file mode 120000
index 00000000..62d5fcfa
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGNodeView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift
new file mode 120000
index 00000000..9b5485a0
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGSidebarView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift
new file mode 120000
index 00000000..d2da7bd8
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGSimulationController.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift
new file mode 120000
index 00000000..4cdeda6d
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/KGViewModel.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift
new file mode 120000
index 00000000..bcad7b75
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift
@@ -0,0 +1 @@
+../../BrainBar/KnowledgeGraph/ScrollWheelZoomView.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift b/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift
new file mode 120000
index 00000000..ffb7d8b4
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift
@@ -0,0 +1 @@
+../BrainBar/MCPFraming.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift b/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift
new file mode 120000
index 00000000..094f6399
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift
@@ -0,0 +1 @@
+../BrainBar/MCPRouter.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift
new file mode 120000
index 00000000..913d3b4e
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift
@@ -0,0 +1 @@
+../../BrainBar/Models/DigestResult.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift b/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift
new file mode 120000
index 00000000..4291a315
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift
@@ -0,0 +1 @@
+../../BrainBar/Models/EntityCard.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift
new file mode 120000
index 00000000..39a62939
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift
@@ -0,0 +1 @@
+../../BrainBar/Models/KGSearchResult.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift
new file mode 120000
index 00000000..e9cc0d3b
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift
@@ -0,0 +1 @@
+../../BrainBar/Models/SearchResult.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift
new file mode 120000
index 00000000..b0679c9d
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift
@@ -0,0 +1 @@
+../../BrainBar/Models/StatsResult.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift b/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift
new file mode 120000
index 00000000..64e241db
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift
@@ -0,0 +1 @@
+../BrainBar/QuickCaptureController.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift
new file mode 120000
index 00000000..c6842360
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift
@@ -0,0 +1 @@
+../BrainBar/QuickCapturePanel.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift
new file mode 120000
index 00000000..add436df
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift
@@ -0,0 +1 @@
+../BrainBar/QuickCapturePanelState.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift b/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift
new file mode 120000
index 00000000..91437902
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift
@@ -0,0 +1 @@
+../BrainBar/SearchFilters.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift b/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift
new file mode 120000
index 00000000..9e274a81
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift
@@ -0,0 +1 @@
+../BrainBar/SearchPanelController.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift b/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift
new file mode 120000
index 00000000..6a6d9e2c
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift
@@ -0,0 +1 @@
+../BrainBar/SearchQueryActor.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift b/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift
new file mode 120000
index 00000000..d4cfe94e
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift
@@ -0,0 +1 @@
+../BrainBar/SearchViewModel.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift
new file mode 120000
index 00000000..f7ad337e
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift
@@ -0,0 +1 @@
+../../../BrainBar/Views/Components/ChunkConversationSheet.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift
new file mode 120000
index 00000000..8130472c
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift
@@ -0,0 +1 @@
+../../../BrainBar/Views/Components/SearchResultCard.swift
\ No newline at end of file
diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift
new file mode 120000
index 00000000..e544978a
--- /dev/null
+++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift
@@ -0,0 +1 @@
+../../../BrainBar/Views/Components/SearchResultsList.swift
\ No newline at end of file
diff --git a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift
index a0f276ac..3db57d7b 100644
--- a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift
+++ b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift
@@ -56,6 +56,7 @@ final class BrainBarReliabilityTests: XCTestCase {
let server = BrainBarServer(
socketPath: socketPath,
dbPath: dbPath,
+ enableHybridSearchHelper: false,
databaseRecoveryPolicy: .init(
busyTimeoutMillis: 1_000,
initialRetryDelayMillis: 25,
diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift
index 8a4fbff6..a649ed5d 100644
--- a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift
+++ b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift
@@ -44,6 +44,7 @@ final class BrainBarStartupRecoveryTests: XCTestCase {
let server = BrainBarServer(
socketPath: socketPath,
dbPath: dbPath,
+ enableHybridSearchHelper: false,
databaseRecoveryPolicy: .init(
busyTimeoutMillis: 50,
initialRetryDelayMillis: 25,
diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift
new file mode 100644
index 00000000..8b4846be
--- /dev/null
+++ b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift
@@ -0,0 +1,53 @@
+import AppKit
+import XCTest
+@testable import BrainBar
+
+@MainActor
+final class BrainBarStatusPopoverControllerTests: XCTestCase {
+ func testControllerPrewarmsVariableLengthStatusItemPopover() {
+ let runtime = BrainBarRuntime(launchMode: .menuBarWindow)
+ let controller = BrainBarStatusPopoverController(runtime: runtime)
+ defer { controller.stop() }
+
+ XCTAssertEqual(controller.statusItemForTesting.length, NSStatusItem.variableLength)
+ XCTAssertEqual(controller.popoverForTesting.behavior, NSPopover.Behavior.transient)
+ XCTAssertNotNil(controller.popoverForTesting.contentViewController)
+ XCTAssertTrue(controller.popoverForTesting.contentViewController?.isViewLoaded == true)
+ }
+
+ func testAppSupportCollectorFactoryWiresBrainBusEvents() {
+ let tempDBPath = NSTemporaryDirectory() + "brainbar-status-popover-\(UUID().uuidString).db"
+ let eventSource = RecordingBrainBusEventSource()
+ let collector = BrainBarAppSupport.makeStatsCollector(
+ dbPath: tempDBPath,
+ targetPID: ProcessInfo.processInfo.processIdentifier,
+ brainBusEvents: eventSource
+ )
+ defer {
+ collector.stop()
+ try? FileManager.default.removeItem(atPath: tempDBPath)
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-wal")
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-shm")
+ }
+
+ collector.start()
+
+ XCTAssertEqual(eventSource.streamRequestCount, 1)
+ }
+}
+
+private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable {
+ private let lock = NSLock()
+ private var requests = 0
+
+ var streamRequestCount: Int {
+ lock.withLock { requests }
+ }
+
+ func events() -> AsyncStream {
+ lock.withLock {
+ requests += 1
+ }
+ return AsyncStream { _ in }
+ }
+}
diff --git a/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift
new file mode 100644
index 00000000..0e17cf3c
--- /dev/null
+++ b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift
@@ -0,0 +1,93 @@
+import XCTest
+@testable import BrainBar
+
+final class BrainBusEventHubTests: XCTestCase {
+ func testSubscribeReceivesThreeStateChangesInOrderWithinLatencyBudget() throws {
+ let hub = BrainBusEventHub(bufferCapacity: 8, heartbeatInterval: nil)
+ let received = LockedBrainBusEvents()
+ let allReceived = XCTestExpectation(description: "received three brain bus events")
+
+ let subscription = hub.subscribe { event in
+ received.append(event)
+ if received.count == 3 {
+ allReceived.fulfill()
+ }
+ return true
+ }
+ defer { hub.unsubscribe(subscription) }
+
+ let startedAt = DispatchTime.now()
+ hub.publish(.queueDepth(2))
+ hub.publish(.enrichStatus("running"))
+ hub.publish(.lastChunkID("chunk-3"))
+
+ wait(for: [allReceived], timeout: 1.0)
+ let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000
+
+ XCTAssertLessThan(elapsedMillis, 50)
+ XCTAssertEqual(received.snapshot().map(\.type), [.queueDepth, .enrichStatus, .lastChunkID])
+ }
+
+ func testBackpressureDropsOldestEventsWithoutStallingPublisher() throws {
+ let hub = BrainBusEventHub(bufferCapacity: 3, heartbeatInterval: nil)
+ let received = LockedBrainBusEvents()
+ let sawLatest = XCTestExpectation(description: "slow subscriber eventually receives latest event")
+
+ let subscription = hub.subscribe { event in
+ Thread.sleep(forTimeInterval: 0.02)
+ received.append(event)
+ if event.sequence == 20 {
+ sawLatest.fulfill()
+ }
+ return true
+ }
+ defer { hub.unsubscribe(subscription) }
+
+ let startedAt = DispatchTime.now()
+ for index in 1...20 {
+ hub.publish(.healthTick(openConnections: index))
+ }
+ let publishElapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000
+
+ XCTAssertLessThan(publishElapsedMillis, 20)
+ wait(for: [sawLatest], timeout: 1.0)
+
+ let delivered = received.snapshot()
+ XCTAssertLessThan(delivered.count, 20)
+ XCTAssertEqual(delivered.last?.sequence, 20)
+ }
+
+ func testSynchronousUnsubscribeRemovesSubscriberBeforeReturn() throws {
+ let hub = BrainBusEventHub(bufferCapacity: 8, heartbeatInterval: nil)
+ let received = LockedBrainBusEvents()
+ let subscription = hub.subscribe { event in
+ received.append(event)
+ return true
+ }
+
+ hub.unsubscribeSynchronously(subscription)
+ hub.publish(.queueDepth(1))
+ Thread.sleep(forTimeInterval: 0.05)
+
+ XCTAssertEqual(received.count, 0)
+ }
+}
+
+private final class LockedBrainBusEvents: @unchecked Sendable {
+ private let lock = NSLock()
+ private var events: [BrainBusEvent] = []
+
+ var count: Int {
+ lock.withLock { events.count }
+ }
+
+ func append(_ event: BrainBusEvent) {
+ lock.withLock {
+ events.append(event)
+ }
+ }
+
+ func snapshot() -> [BrainBusEvent] {
+ lock.withLock { events }
+ }
+}
diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift
index 0a30bbf8..6a20c8b4 100644
--- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift
+++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift
@@ -255,6 +255,82 @@ final class DashboardTests: XCTestCase {
XCTAssertEqual(collector.stats.chunkCount, 1)
}
+ @MainActor
+ func testReadOnlyStatsCollectorReopensAfterDaemonCreatesDatabase() throws {
+ db.close()
+ try? FileManager.default.removeItem(atPath: tempDBPath)
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-wal")
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-shm")
+
+ let collector = StatsCollector(
+ dbPath: tempDBPath,
+ daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier),
+ databaseOpenConfiguration: BrainDatabase.OpenConfiguration(readOnly: true)
+ )
+ defer { collector.stop() }
+
+ collector.refresh(force: true)
+ XCTAssertEqual(collector.stats.chunkCount, 0)
+
+ let writer = BrainDatabase(path: tempDBPath)
+ defer { writer.close() }
+ try writer.insertChunk(
+ id: "dash-readonly-late-db",
+ content: "Daemon created the database after UI startup",
+ sessionId: "dashboard",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ collector.refresh(force: true)
+
+ XCTAssertEqual(collector.stats.chunkCount, 1)
+ }
+
+ @MainActor
+ func testStatsCollectorSubscribesToBrainBusWithoutPollingDelay() {
+ let eventSource = RecordingBrainBusEventSource()
+ let collector = StatsCollector(
+ dbPath: tempDBPath,
+ daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier),
+ brainBusEvents: eventSource
+ )
+ defer { collector.stop() }
+
+ let startedAt = DispatchTime.now()
+ collector.start()
+ let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000
+
+ XCTAssertEqual(eventSource.streamRequestCount, 1)
+ XCTAssertLessThan(elapsedMillis, 1_000)
+ }
+
+ @MainActor
+ func testStatsCollectorRefreshesImmediatelyWithBrainBusEvents() throws {
+ try db.insertChunk(
+ id: "dash-preexisting",
+ content: "Inserted before collector start",
+ sessionId: "dashboard",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ let eventSource = RecordingBrainBusEventSource()
+ let collector = StatsCollector(
+ dbPath: tempDBPath,
+ daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier),
+ brainBusEvents: eventSource
+ )
+ defer { collector.stop() }
+
+ collector.start()
+
+ XCTAssertEqual(eventSource.streamRequestCount, 1)
+ XCTAssertEqual(collector.stats.chunkCount, 1)
+ }
+
func testPipelineStateTreatsMissingDaemonSnapshotAsDegraded() {
let stats = DashboardStats(
chunkCount: 10,
@@ -387,3 +463,19 @@ final class DashboardTests: XCTestCase {
XCTAssertTrue(viewController.isViewLoaded)
}
}
+
+private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable {
+ private let lock = NSLock()
+ private var requests = 0
+
+ var streamRequestCount: Int {
+ lock.withLock { requests }
+ }
+
+ func events() -> AsyncStream {
+ lock.withLock {
+ requests += 1
+ }
+ return AsyncStream { _ in }
+ }
+}
diff --git a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift
index 2c86db7f..122473d8 100644
--- a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift
+++ b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift
@@ -82,6 +82,47 @@ final class DatabaseTests: XCTestCase {
XCTAssertTrue(exists, "brainbar_subscriptions table must exist")
}
+ func testSchemaMigrationsTableIncludesDetailsForHybridHelperParity() throws {
+ let columns = try sqliteTableColumns(path: tempDBPath, table: "schema_migrations")
+
+ XCTAssertTrue(columns.contains("name"))
+ XCTAssertTrue(columns.contains("applied_at"))
+ XCTAssertTrue(
+ columns.contains("details"),
+ "Swift startup must keep schema_migrations compatible with the Phase D Python helper"
+ )
+ }
+
+ func testReadOnlyOpenConfigurationAllowsDashboardReadsButRejectsWrites() throws {
+ try db.insertChunk(
+ id: "readonly-seed",
+ content: "Seed row for read-only dashboard stats",
+ sessionId: "readonly",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ let reader = BrainDatabase(
+ path: tempDBPath,
+ openConfiguration: .init(readOnly: true)
+ )
+ defer { reader.close() }
+
+ let stats = try reader.dashboardStats(activityWindowMinutes: 30, bucketCount: 6)
+ XCTAssertEqual(stats.chunkCount, 1)
+ XCTAssertThrowsError(
+ try reader.insertChunk(
+ id: "readonly-write",
+ content: "This write must not land from the UI process",
+ sessionId: "readonly",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+ )
+ }
+
func testInjectionEventsTableExists() throws {
let exists = try db.tableExists("injection_events")
XCTAssertTrue(exists, "injection_events table must exist")
@@ -1029,6 +1070,25 @@ private func sqliteQueryPlan(path: String, sql: String, binds: [String]) throws
}
}
+private func sqliteTableColumns(path: String, table: String) throws -> Set {
+ try withSQLiteConnection(path: path) { db in
+ var stmt: OpaquePointer?
+ let rc = sqlite3_prepare_v2(db, "PRAGMA table_info(\(table))", -1, &stmt, nil)
+ guard rc == SQLITE_OK else {
+ throw NSError(domain: "DatabaseTests", code: Int(rc))
+ }
+ defer { sqlite3_finalize(stmt) }
+
+ var columns = Set()
+ while sqlite3_step(stmt) == SQLITE_ROW {
+ if let text = sqlite3_column_text(stmt, 1) {
+ columns.insert(String(cString: text))
+ }
+ }
+ return columns
+ }
+}
+
private func withSQLiteConnection(path: String, body: (OpaquePointer) throws -> T) throws -> T {
var db: OpaquePointer?
let rc = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil)
diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift
new file mode 100644
index 00000000..e5706fc8
--- /dev/null
+++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift
@@ -0,0 +1,124 @@
+import Darwin
+import XCTest
+@testable import BrainBar
+
+final class HybridSearchHelperClientTests: XCTestCase {
+ deinit {}
+
+ func testResolvePythonExecutableUsesInstallTimeRepoRoot() throws {
+ let repoRoot = NSTemporaryDirectory() + "brainbar-helper-repo-\(UUID().uuidString)"
+ let pythonPath = "\(repoRoot)/.venv/bin/python"
+ try FileManager.default.createDirectory(
+ atPath: "\(repoRoot)/.venv/bin",
+ withIntermediateDirectories: true
+ )
+ FileManager.default.createFile(atPath: pythonPath, contents: Data("#!/bin/sh\n".utf8))
+ chmod(pythonPath, 0o755)
+ defer { try? FileManager.default.removeItem(atPath: repoRoot) }
+
+ let resolved = HybridSearchHelperClient.resolvePythonExecutable(environment: [
+ "BRAINLAYER_REPO_ROOT": repoRoot,
+ "PATH": "/nonexistent"
+ ])
+
+ XCTAssertEqual(resolved, pythonPath)
+ }
+
+ func testResolvePythonPathUsesRepoSourceDirectoryWhenUnset() throws {
+ let repoRoot = NSTemporaryDirectory() + "brainbar-helper-src-\(UUID().uuidString)"
+ try FileManager.default.createDirectory(
+ atPath: "\(repoRoot)/src",
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(atPath: repoRoot) }
+
+ let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [
+ "BRAINLAYER_REPO_ROOT": repoRoot
+ ])
+
+ XCTAssertEqual(resolved, "\(repoRoot)/src")
+ }
+
+ func testResolvePythonPathPreservesExistingPythonPath() throws {
+ let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [
+ "PYTHONPATH": "/custom/pythonpath",
+ "BRAINLAYER_REPO_ROOT": "/ignored"
+ ])
+
+ XCTAssertEqual(resolved, "/custom/pythonpath")
+ }
+
+ func testSearchReportsLaunchFailureWithoutSocketRetry() throws {
+ let client = HybridSearchHelperClient(
+ socketPath: "/tmp/bb-missing-\(UUID().uuidString).sock",
+ dbPath: "/tmp/brainlayer-test.db",
+ pythonExecutable: "/no/such/python",
+ environment: [:]
+ )
+
+ do {
+ _ = try client.search(arguments: ["query": "techgym speakers workshop"])
+ XCTFail("Expected launch failure")
+ } catch let error as HybridSearchHelperError {
+ guard case .launch = error else {
+ return XCTFail("Expected launch error, got \(error)")
+ }
+ }
+ }
+
+ func testSearchRejectsTooLongSocketPathBeforeLaunch() throws {
+ let longPath = "/tmp/" + String(repeating: "x", count: 200) + ".sock"
+ let client = HybridSearchHelperClient(
+ socketPath: longPath,
+ dbPath: "/tmp/brainlayer-test.db",
+ pythonExecutable: "/no/such/python",
+ environment: [:]
+ )
+
+ do {
+ _ = try client.search(arguments: ["query": "techgym speakers workshop"])
+ XCTFail("Expected socket path validation failure")
+ } catch let error as HybridSearchHelperError {
+ guard case .socketPathTooLong(let path) = error else {
+ return XCTFail("Expected socket path error, got \(error)")
+ }
+ XCTAssertEqual(path, longPath)
+ }
+ }
+
+ func testConfigureSocketTimeoutsMakesReadsFinite() throws {
+ var fds: [Int32] = [0, 0]
+ XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
+ defer {
+ close(fds[0])
+ close(fds[1])
+ }
+
+ try HybridSearchHelperClient.configureSocketTimeouts(fd: fds[0], timeout: 0.05)
+
+ var byte = UInt8(0)
+ let startedAt = Date()
+ let count = read(fds[0], &byte, 1)
+ let elapsed = Date().timeIntervalSince(startedAt)
+
+ XCTAssertEqual(count, -1)
+ XCTAssertTrue(errno == EAGAIN || errno == EWOULDBLOCK)
+ XCTAssertLessThan(elapsed, 1.0)
+ }
+
+ func testConfigureNoSigpipeAcceptsOpenUnixSocket() throws {
+ var fds: [Int32] = [0, 0]
+ XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
+ defer {
+ close(fds[0])
+ close(fds[1])
+ }
+
+ try HybridSearchHelperClient.configureNoSigpipe(fd: fds[0])
+
+ var value: Int32 = 0
+ var length = socklen_t(MemoryLayout.size)
+ XCTAssertEqual(getsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &value, &length), 0)
+ XCTAssertEqual(value, 1)
+ }
+}
diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift
index 847fcddd..005ce211 100644
--- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift
+++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift
@@ -292,6 +292,237 @@ final class MCPRouterTests: XCTestCase {
XCTAssertEqual(response["id"] as? Int, 4)
}
+ func testBrainSearchDelegatesNormalQueriesToHybridHelper() throws {
+ let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-helper-\(UUID().uuidString).db"
+ defer { try? FileManager.default.removeItem(atPath: tempDB) }
+ let db = BrainDatabase(path: tempDB)
+ defer { db.close() }
+ try db.insertChunk(
+ id: "fts-only-decoy",
+ content: "techgym speakers workshop decoy from the old Swift FTS path",
+ sessionId: "s1",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 1
+ )
+
+ let helper = RecordingHybridSearchClient(
+ response: HybridSearchResponse(
+ text: #"""
+┌─ brain_search: "techgym speakers workshop" ─ 1 result
+├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16
+│ brainlayer │ Michal speakers workshop manual chunk
+└─
+"""#,
+ metadata: [
+ "content": "helper must not overwrite MCP content",
+ "isError": true,
+ "structuredContent": ["query": "techgym speakers workshop"]
+ ]
+ )
+ )
+ let router = MCPRouter(hybridSearchClient: helper)
+ router.setDatabase(db)
+
+ let response = router.handle([
+ "jsonrpc": "2.0",
+ "id": 17,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": [
+ "query": "techgym speakers workshop",
+ "num_results": 3,
+ "project": "brainlayer",
+ "source": "all",
+ "tag": "speakers-workshop",
+ "importance_min": 8,
+ "detail": "compact"
+ ] as [String: Any]
+ ] as [String: Any]
+ ])
+
+ let result = try XCTUnwrap(response["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ let text = content.first?["text"] as? String ?? ""
+
+ XCTAssertTrue(text.contains("manual-a0b8a"))
+ XCTAssertFalse(text.contains("fts-only-decoy"), "Normal MCP brain_search must use the Python hybrid helper, not Swift FTS.")
+ XCTAssertEqual(helper.requests.count, 1)
+ XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop")
+ XCTAssertEqual(helper.requests.first?["num_results"] as? Int, 3)
+ XCTAssertEqual(helper.requests.first?["project"] as? String, "brainlayer")
+ XCTAssertEqual(helper.requests.first?["source"] as? String, "all")
+ XCTAssertEqual(helper.requests.first?["tag"] as? String, "speakers-workshop")
+ XCTAssertEqual(helper.requests.first?["importance_min"] as? Double, 8)
+ XCTAssertEqual(helper.requests.first?["detail"] as? String, "compact")
+ XCTAssertNotNil(result["structuredContent"])
+ XCTAssertNil(result["isError"], "Hybrid helper metadata must not overwrite reserved MCP result keys.")
+ XCTAssertNotNil(result["content"] as? [[String: Any]], "Hybrid helper metadata must not overwrite MCP content.")
+ }
+
+ func testBrainSearchHybridSuccessDoesNotPrependSwiftKGFacts() throws {
+ let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-no-duplicate-kg-\(UUID().uuidString).db"
+ defer { try? FileManager.default.removeItem(atPath: tempDB) }
+ let db = BrainDatabase(path: tempDB)
+ defer { db.close() }
+ try db.insertEntity(id: "entity-etan", type: "person", name: "Etan")
+ try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer")
+ try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on")
+ try db.insertChunk(
+ id: "swift-fts-result",
+ content: "Etan BrainLayer local fallback result",
+ sessionId: "s1",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ let helperText = "python helper canonical KG/search output"
+ let helper = RecordingHybridSearchClient(response: HybridSearchResponse(text: helperText))
+ let router = MCPRouter(hybridSearchClient: helper)
+ router.setDatabase(db)
+
+ let response = router.handle([
+ "jsonrpc": "2.0",
+ "id": 171,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": ["query": "Etan BrainLayer", "num_results": 3]
+ ] as [String: Any]
+ ])
+
+ let result = try XCTUnwrap(response["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ let text = content.first?["text"] as? String ?? ""
+
+ XCTAssertEqual(text, helperText)
+ XCTAssertEqual(helper.requests.count, 1)
+ XCTAssertFalse(text.contains("### ◆ Etan"), "Swift KG facts must not duplicate Python helper entity output.")
+ XCTAssertFalse(text.contains("WORKS_ON"), "Swift KG facts must not be prepended on hybrid helper success.")
+ }
+
+ func testBrainSearchFallsBackToBrainBarDatabaseWhenHybridHelperFails() throws {
+ let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-\(UUID().uuidString).db"
+ defer { try? FileManager.default.removeItem(atPath: tempDB) }
+ let db = BrainDatabase(path: tempDB)
+ defer { db.close() }
+ try db.insertChunk(
+ id: "fallback-fts-result",
+ content: "techgym speakers workshop fallback result from BrainBar database search",
+ sessionId: "s1",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ let helper = RecordingHybridSearchClient()
+ let router = MCPRouter(hybridSearchClient: helper)
+ router.setDatabase(db)
+
+ let response = router.handle([
+ "jsonrpc": "2.0",
+ "id": 177,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": ["query": "fallback", "num_results": 3]
+ ] as [String: Any]
+ ])
+
+ let result = try XCTUnwrap(response["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ let text = content.first?["text"] as? String ?? ""
+
+ XCTAssertEqual(helper.requests.count, 1)
+ XCTAssertTrue(text.contains("fallback-fts"), text)
+ XCTAssertNil(result["structuredContent"])
+ }
+
+ func testBrainSearchFallbackStillPrependsSwiftKGFacts() throws {
+ let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-kg-\(UUID().uuidString).db"
+ defer { try? FileManager.default.removeItem(atPath: tempDB) }
+ let db = BrainDatabase(path: tempDB)
+ defer { db.close() }
+ try db.insertEntity(id: "entity-etan", type: "person", name: "Etan")
+ try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer")
+ try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on")
+ try db.insertChunk(
+ id: "fallback-kg-result",
+ content: "Etan BrainLayer fallback result from BrainBar database search",
+ sessionId: "s1",
+ project: "brainlayer",
+ contentType: "assistant_text",
+ importance: 5
+ )
+
+ let helper = RecordingHybridSearchClient()
+ let router = MCPRouter(hybridSearchClient: helper)
+ router.setDatabase(db)
+
+ let response = router.handle([
+ "jsonrpc": "2.0",
+ "id": 172,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": ["query": "Etan BrainLayer", "num_results": 3]
+ ] as [String: Any]
+ ])
+
+ let result = try XCTUnwrap(response["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ let text = content.first?["text"] as? String ?? ""
+
+ XCTAssertEqual(helper.requests.count, 1)
+ XCTAssertTrue(text.contains("### ◆ Etan"), text)
+ XCTAssertTrue(text.contains("WORKS_ON"), text)
+ XCTAssertTrue(text.contains("fallback result from BrainBar database search"), text)
+ }
+
+ func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws {
+ let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db"
+ defer { try? FileManager.default.removeItem(atPath: tempDB) }
+ let db = BrainDatabase(path: tempDB)
+ defer { db.close() }
+
+ try db.insertChunk(id: "read-1", content: "Agent message already delivered", sessionId: "s1", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]")
+ try db.insertChunk(id: "unread-1", content: "Agent message still unread", sessionId: "s2", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]")
+ _ = try db.upsertSubscription(agentID: "agent-1", tags: ["agent-message"])
+ guard let readSeq = try db.chunkRowID(forChunkID: "read-1") else {
+ XCTFail("expected read-1 rowid")
+ return
+ }
+ try db.acknowledge(agentID: "agent-1", seq: readSeq)
+
+ let helper = RecordingHybridSearchClient(
+ response: HybridSearchResponse(text: "helper should not be called")
+ )
+ let router = MCPRouter(hybridSearchClient: helper)
+ router.setDatabase(db)
+ let response = router.handle([
+ "jsonrpc": "2.0",
+ "id": 18,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": [
+ "query": "agent message",
+ "subscriber_id": "agent-1",
+ "unread_only": true
+ ] as [String: Any]
+ ] as [String: Any]
+ ])
+
+ let result = response["result"] as? [String: Any]
+ let content = result?["content"] as? [[String: Any]]
+ let text = content?.first?["text"] as? String ?? ""
+
+ XCTAssertTrue(text.contains("unread-1"), "Unread queue searches must preserve BrainBar subscriber cursor behavior.")
+ XCTAssertEqual(helper.requests.count, 0)
+ }
+
func testBrainMaintenanceRebuildTrigramToolReturnsProgressMetadata() throws {
let tempDB = NSTemporaryDirectory() + "brainbar-maintenance-\(UUID().uuidString).db"
defer {
diff --git a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift
new file mode 100644
index 00000000..bbb7d754
--- /dev/null
+++ b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift
@@ -0,0 +1,42 @@
+import Foundation
+@testable import BrainBar
+
+enum RecordingHybridSearchClientError: LocalizedError {
+ case injectedFailure
+
+ var errorDescription: String? {
+ switch self {
+ case .injectedFailure:
+ return "injected hybrid search failure"
+ }
+ }
+}
+
+final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable {
+ private let result: Result
+ private let lock = NSLock()
+ private var recordedRequests: [[String: Any]] = []
+
+ var requests: [[String: Any]] {
+ lock.lock()
+ defer { lock.unlock() }
+ return recordedRequests
+ }
+
+ init(response: HybridSearchResponse) {
+ result = .success(response)
+ }
+
+ init(error: Error = RecordingHybridSearchClientError.injectedFailure) {
+ result = .failure(error)
+ }
+
+ deinit {}
+
+ func search(arguments: [String: Any]) throws -> HybridSearchResponse {
+ lock.lock()
+ recordedRequests.append(arguments)
+ lock.unlock()
+ return try result.get()
+ }
+}
diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift
index d6ea899f..03963134 100644
--- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift
+++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift
@@ -10,18 +10,24 @@ import XCTest
final class SocketIntegrationTests: XCTestCase {
let testSocketPath = "/tmp/brainbar-test-\(ProcessInfo.processInfo.processIdentifier).sock"
var server: BrainBarServer!
+ var db: BrainDatabase!
+ var tempDBPath: String!
override func setUp() {
super.setUp()
- let tempDB = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db"
- server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDB)
+ tempDBPath = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db"
+ db = BrainDatabase(path: tempDBPath)
+ server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db)
server.start()
- // Give server time to bind
- Thread.sleep(forTimeInterval: 0.2)
+ XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)")
}
override func tearDown() {
server.stop()
+ db.close()
+ try? FileManager.default.removeItem(atPath: tempDBPath)
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-wal")
+ try? FileManager.default.removeItem(atPath: tempDBPath + "-shm")
super.tearDown()
}
@@ -145,6 +151,38 @@ final class SocketIntegrationTests: XCTestCase {
}
}
+ func testWatchBrainBusStreamsStoreEventsOverRawUnixSocket() throws {
+ let watchFD = try connectClient()
+ defer { close(watchFD) }
+
+ try sendRawLineJSON(on: watchFD, object: [
+ "jsonrpc": "2.0",
+ "id": 50,
+ "method": "watch-brain-bus",
+ ])
+ _ = try readBrainBusEvent(fd: watchFD, matching: "health_tick")
+
+ let storeResponse = try sendMCPRequest([
+ "jsonrpc": "2.0",
+ "id": 51,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_store",
+ "arguments": [
+ "content": "Brain bus stream integration",
+ "tags": ["agent-message"]
+ ] as [String: Any]
+ ]
+ ])
+ XCTAssertNil(storeResponse["error"])
+
+ let event = try readBrainBusEvent(fd: watchFD, matching: "last_chunk_id")
+ XCTAssertEqual(event["method"] as? String, "notifications/brain-bus")
+ let params = try XCTUnwrap(event["params"] as? [String: Any])
+ XCTAssertEqual(params["type"] as? String, "last_chunk_id")
+ XCTAssertFalse((params["last_chunk_id"] as? String ?? "").isEmpty)
+ }
+
// MARK: - MCP tools/call brain_search over socket
func testMCPBrainSearchOverSocket() throws {
@@ -216,6 +254,47 @@ final class SocketIntegrationTests: XCTestCase {
)
}
+ func testMCPBrainSearchOverSocketUsesInjectedHybridHelper() throws {
+ server.stop()
+ let helper = RecordingHybridSearchClient(
+ response: HybridSearchResponse(
+ text: #"""
+┌─ brain_search: "techgym speakers workshop" ─ 1 result
+├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16
+└─
+"""#
+ )
+ )
+ server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db, hybridSearchClient: helper)
+ server.start()
+ XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)")
+
+ _ = try sendMCPRequest([
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
+ "params": ["protocolVersion": "2024-11-05", "capabilities": [:] as [String: Any],
+ "clientInfo": ["name": "test", "version": "1.0"]]
+ ])
+
+ let response = try sendMCPRequest([
+ "jsonrpc": "2.0",
+ "id": 19,
+ "method": "tools/call",
+ "params": [
+ "name": "brain_search",
+ "arguments": ["query": "techgym speakers workshop", "num_results": 3, "source": "all"] as [String: Any]
+ ]
+ ])
+
+ let result = try XCTUnwrap(response["result"] as? [String: Any])
+ let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+ let text = content.first?["text"] as? String ?? ""
+
+ XCTAssertTrue(text.contains("manual-a0b8a"))
+ XCTAssertEqual(helper.requests.count, 1)
+ XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop")
+ XCTAssertEqual(helper.requests.first?["source"] as? String, "all")
+ }
+
func testMCPBrainSubscribeOverSocketReturnsCursorState() throws {
_ = try sendMCPRequest([
"jsonrpc": "2.0", "id": 1, "method": "initialize",
@@ -419,9 +498,14 @@ final class SocketIntegrationTests: XCTestCase {
unsetenv("BRAINBAR_PENDING_STORES_PATH")
}
}
- server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath)
+ let flushDB = BrainDatabase(path: dbPath)
+ server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath, database: flushDB)
+ defer {
+ server.stop()
+ flushDB.close()
+ }
server.start()
- Thread.sleep(forTimeInterval: 0.2)
+ XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)")
let subscriberFD = try connectClient()
defer { close(subscriberFD) }
@@ -761,7 +845,15 @@ final class SocketIntegrationTests: XCTestCase {
func testRejectsOverlongSocketPath() throws {
// sockaddr_un.sun_path is 104 bytes on macOS. A path > 104 should not crash.
let longPath = "/tmp/" + String(repeating: "x", count: 200) + ".sock"
- let longServer = BrainBarServer(socketPath: longPath, dbPath: NSTemporaryDirectory() + "test-long.db")
+ let longDBPath = NSTemporaryDirectory() + "test-long-\(UUID().uuidString).db"
+ let longDB = BrainDatabase(path: longDBPath)
+ defer {
+ longDB.close()
+ try? FileManager.default.removeItem(atPath: longDBPath)
+ try? FileManager.default.removeItem(atPath: longDBPath + "-wal")
+ try? FileManager.default.removeItem(atPath: longDBPath + "-shm")
+ }
+ let longServer = BrainBarServer(socketPath: longPath, dbPath: longDBPath, database: longDB)
longServer.start()
Thread.sleep(forTimeInterval: 0.2)
@@ -782,6 +874,17 @@ final class SocketIntegrationTests: XCTestCase {
// MARK: - Helper
+ private func waitForSocket(at path: String, timeout: TimeInterval = 3.0) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if FileManager.default.fileExists(atPath: path) {
+ return true
+ }
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ return FileManager.default.fileExists(atPath: path)
+ }
+
private func sendMCPRequest(_ request: [String: Any]) throws -> [String: Any] {
let fd = try connectClient()
defer { close(fd) }
@@ -891,6 +994,35 @@ final class SocketIntegrationTests: XCTestCase {
throw NSError(domain: "test", code: 4, userInfo: [NSLocalizedDescriptionKey: "Timeout reading raw line response"])
}
+ private func readBrainBusEvent(fd: Int32, matching type: String, timeout: TimeInterval = 5.0) throws -> [String: Any] {
+ let deadline = Date().addingTimeInterval(timeout)
+ var buffer = Data()
+ var readBuf = [UInt8](repeating: 0, count: 65536)
+ while Date() < deadline {
+ while let newlineIndex = buffer.firstIndex(of: 0x0A) {
+ let line = Data(buffer[.. 0 {
+ buffer.append(contentsOf: readBuf[0.. [String: Any] {
return try readMCPMessages(fd: fd, expectedCount: 1, timeout: timeout).first ?? [:]
}
diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh
index 21315af0..cf416a90 100755
--- a/brain-bar/build-app.sh
+++ b/brain-bar/build-app.sh
@@ -51,10 +51,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGE_DIR="$SCRIPT_DIR"
BUNDLE_DIR="$SCRIPT_DIR/bundle"
SIGN_IDENTITY="${BRAINBAR_CODESIGN_IDENTITY:-Apple Development: Etan Heyman (DXHB5E7P2D)}"
-PLIST_LABEL="com.brainlayer.brainbar"
-PLIST_FILENAME="$PLIST_LABEL.plist"
-PLIST_SRC="$BUNDLE_DIR/$PLIST_FILENAME"
-PLIST_DST="$HOME/Library/LaunchAgents/$PLIST_FILENAME"
+UI_PLIST_LABEL="com.brainlayer.brainbar"
+DAEMON_PLIST_LABEL="com.brainlayer.brainbar-daemon"
+UI_PLIST_FILENAME="$UI_PLIST_LABEL.plist"
+DAEMON_PLIST_FILENAME="$DAEMON_PLIST_LABEL.plist"
+UI_PLIST_SRC="$BUNDLE_DIR/$UI_PLIST_FILENAME"
+DAEMON_PLIST_SRC="$BUNDLE_DIR/$DAEMON_PLIST_FILENAME"
+UI_PLIST_DST="$HOME/Library/LaunchAgents/$UI_PLIST_FILENAME"
+DAEMON_PLIST_DST="$HOME/Library/LaunchAgents/$DAEMON_PLIST_FILENAME"
LAUNCH_DOMAIN="gui/$(id -u)"
SOCKET_PATH="${BRAINBAR_SOCKET_PATH:-/tmp/brainbar.sock}"
PLIST_BUDDY="/usr/libexec/PlistBuddy"
@@ -112,9 +116,10 @@ if [ "$DRY_RUN" -eq 1 ]; then
echo "[build-app] Repo: $CURRENT_REPO_ROOT"
echo "[build-app] App path: $APP_DIR"
if [ "$DEV_BUNDLE_BUILD" -eq 1 ]; then
- echo "[build-app] LaunchAgent: skipped for DEV worktree build"
+ echo "[build-app] LaunchAgents: skipped for DEV worktree build"
else
- echo "[build-app] LaunchAgent: canonical install to $PLIST_DST"
+ echo "[build-app] UI LaunchAgent: canonical install to $UI_PLIST_DST"
+ echo "[build-app] Daemon LaunchAgent: canonical install to $DAEMON_PLIST_DST"
fi
exit 0
fi
@@ -143,6 +148,22 @@ plist_set_string() {
fi
}
+configure_launchagent_environment() {
+ local plist_path="$1"
+ local repo_root="$2"
+ local python_path="$repo_root/.venv/bin/python"
+
+ "$PLIST_BUDDY" -c "Delete :EnvironmentVariables" "$plist_path" >/dev/null 2>&1 || true
+ "$PLIST_BUDDY" -c "Add :EnvironmentVariables dict" "$plist_path"
+ "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINLAYER_REPO_ROOT string \"$repo_root\"" "$plist_path"
+ if [ -d "$repo_root/src" ]; then
+ "$PLIST_BUDDY" -c "Add :EnvironmentVariables:PYTHONPATH string \"$repo_root/src\"" "$plist_path"
+ fi
+ if [ -x "$python_path" ]; then
+ "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINBAR_PYTHON string \"$python_path\"" "$plist_path"
+ fi
+}
+
stamp_info_plist() {
local plist_path="$1"
local commit_sha="$2"
@@ -155,9 +176,13 @@ stamp_info_plist() {
}
bootout_launchagent() {
- launchctl bootout "$LAUNCH_DOMAIN/$PLIST_LABEL" 2>/dev/null || true
- if [ -f "$PLIST_DST" ]; then
- launchctl bootout "$LAUNCH_DOMAIN" "$PLIST_DST" 2>/dev/null || true
+ launchctl bootout "$LAUNCH_DOMAIN/$UI_PLIST_LABEL" 2>/dev/null || true
+ launchctl bootout "$LAUNCH_DOMAIN/$DAEMON_PLIST_LABEL" 2>/dev/null || true
+ if [ -f "$UI_PLIST_DST" ]; then
+ launchctl bootout "$LAUNCH_DOMAIN" "$UI_PLIST_DST" 2>/dev/null || true
+ fi
+ if [ -f "$DAEMON_PLIST_DST" ]; then
+ launchctl bootout "$LAUNCH_DOMAIN" "$DAEMON_PLIST_DST" 2>/dev/null || true
fi
}
@@ -171,9 +196,20 @@ wait_for_brainbar_exit() {
return 1
}
+wait_for_brainbar_daemon_exit() {
+ for _ in $(seq 1 100); do
+ if ! pgrep -x BrainBarDaemon > /dev/null 2>&1; then
+ return 0
+ fi
+ sleep 0.2
+ done
+ return 1
+}
+
wait_for_socket() {
local path="$1"
- for _ in $(seq 1 100); do
+ local attempts="${BRAINBAR_SOCKET_WAIT_ATTEMPTS:-300}"
+ for _ in $(seq 1 "$attempts"); do
if [ -S "$path" ]; then
return 0
fi
@@ -183,9 +219,9 @@ wait_for_socket() {
}
if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then
- # Stop LaunchAgent first so KeepAlive cannot race the rebuild and unlink the
+ # Stop LaunchAgents first so KeepAlive cannot race the rebuild and unlink the
# freshly rebound socket from an older instance that is still terminating.
- echo "[build-app] Stopping LaunchAgent..."
+ echo "[build-app] Stopping LaunchAgents..."
bootout_launchagent
# Kill any running BrainBar instances before installing.
@@ -198,21 +234,36 @@ if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then
exit 1
fi
fi
+ if pgrep -x BrainBarDaemon > /dev/null 2>&1; then
+ echo "[build-app] Stopping running BrainBarDaemon instances..."
+ killall BrainBarDaemon 2>/dev/null || true
+ if ! wait_for_brainbar_daemon_exit; then
+ echo "[build-app] ERROR: BrainBarDaemon did not exit cleanly"
+ pgrep -fl BrainBarDaemon || true
+ exit 1
+ fi
+ fi
rm -f "$SOCKET_PATH"
else
echo "[build-app] DEV worktree build: preserving canonical LaunchAgent and socket"
fi
-echo "[build-app] Building BrainBar (release)..."
-swift build -c release --package-path "$PACKAGE_DIR"
+echo "[build-app] Building BrainBar and BrainBarDaemon (release)..."
+swift build -c release --package-path "$PACKAGE_DIR" --product BrainBar
+swift build -c release --package-path "$PACKAGE_DIR" --product BrainBarDaemon
# Find the built binary
BIN_DIR="$(swift build -c release --package-path "$PACKAGE_DIR" --show-bin-path)"
BINARY="$BIN_DIR/BrainBar"
+DAEMON_BINARY="$BIN_DIR/BrainBarDaemon"
if [ ! -f "$BINARY" ]; then
echo "[build-app] ERROR: Binary not found at $BINARY"
exit 1
fi
+if [ ! -f "$DAEMON_BINARY" ]; then
+ echo "[build-app] ERROR: Daemon binary not found at $DAEMON_BINARY"
+ exit 1
+fi
# Clean stale bundle
if [ -d "$APP_DIR" ]; then
@@ -227,6 +278,7 @@ mkdir -p "$BRAINLAYER_LOG_DIR"
cp "$BUNDLE_DIR/Info.plist" "$APP_DIR/Contents/"
cp "$BINARY" "$APP_DIR/Contents/MacOS/BrainBar"
+cp "$DAEMON_BINARY" "$APP_DIR/Contents/MacOS/BrainBarDaemon"
COMMIT_SHA="$(git_commit)"
DESCRIBE_REF="$(git_describe)"
@@ -251,17 +303,34 @@ fi
# Register URL scheme with Launch Services (ensures brainbar:// works after rebuild)
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -R "$APP_DIR"
-# Install LaunchAgent (expands path to actual APP_DIR)
-if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then
- echo "[build-app] Installing LaunchAgent to $PLIST_DST..."
- bootout_launchagent
+install_launchagent() {
+ local source_plist="$1"
+ local target_plist="$2"
+ local label="$3"
+
+ echo "[build-app] Installing LaunchAgent to $target_plist..."
+ TMP_PLIST="$(mktemp)"
+ trap 'rm -f "$TMP_PLIST"' EXIT
sed \
-e "s|/Applications/BrainBar.app|$APP_DIR|g" \
-e "s|__HOME__|$HOME|g" \
- "$PLIST_SRC" > "$PLIST_DST"
- launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST"
- launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL"
- echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit"
+ "$source_plist" > "$TMP_PLIST"
+ configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT"
+ mv "$TMP_PLIST" "$target_plist"
+ trap - EXIT
+ launchctl bootstrap "$LAUNCH_DOMAIN" "$target_plist"
+ launchctl kickstart -k "$LAUNCH_DOMAIN/$label"
+}
+
+# Install LaunchAgents (expands path to actual APP_DIR)
+if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then
+ if [ -f "$DAEMON_PLIST_SRC" ]; then
+ install_launchagent "$DAEMON_PLIST_SRC" "$DAEMON_PLIST_DST" "$DAEMON_PLIST_LABEL"
+ fi
+ if [ -f "$UI_PLIST_SRC" ]; then
+ install_launchagent "$UI_PLIST_SRC" "$UI_PLIST_DST" "$UI_PLIST_LABEL"
+ fi
+ echo "[build-app] LaunchAgents installed — daemon and UI restart independently"
fi
if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then
diff --git a/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist b/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist
new file mode 100644
index 00000000..e7608d0b
--- /dev/null
+++ b/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ Label
+ com.brainlayer.brainbar-daemon
+ ProgramArguments
+
+ /Applications/BrainBar.app/Contents/MacOS/BrainBarDaemon
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/brainbar-daemon.stdout.log
+ StandardErrorPath
+ /tmp/brainbar-daemon.stderr.log
+ ProcessType
+ Interactive
+ ThrottleInterval
+ 10
+
+
diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py
new file mode 100644
index 00000000..f3ee2646
--- /dev/null
+++ b/src/brainlayer/brainbar_hybrid_helper.py
@@ -0,0 +1,203 @@
+"""Persistent BrainBar helper for canonical Python hybrid search.
+
+BrainBar owns the MCP socket and write queue. This helper owns the Python
+retrieval stack so BrainBar search results use the same RRF hybrid path as the
+Python MCP implementation without porting ranking logic to Swift.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import signal
+import socket
+import sys
+from pathlib import Path
+from typing import Any
+
+_ACCEPT_TIMEOUT_SECONDS = 0.25
+_CONNECTION_TIMEOUT_SECONDS = 5.0
+
+
+def _json_safe(value: Any) -> Any:
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, list):
+ return [_json_safe(item) for item in value]
+ if isinstance(value, tuple):
+ return [_json_safe(item) for item in value]
+ if isinstance(value, dict):
+ return {str(key): _json_safe(item) for key, item in value.items()}
+ return str(value)
+
+
+class HybridSearchHelper:
+ def __init__(self, socket_path: Path, db_path: Path):
+ self.socket_path = socket_path
+ self.db_path = db_path
+ self._stopped = False
+
+ def warm(self) -> None:
+ os.environ["BRAINLAYER_DB"] = os.fspath(self.db_path)
+ from brainlayer.mcp._shared import _get_embedding_model, _get_vector_store
+
+ _get_vector_store()
+ model = _get_embedding_model()
+ model.embed_query("brainbar hybrid helper warmup")
+
+ def serve_forever(self) -> None:
+ if self.socket_path.exists():
+ self.socket_path.unlink()
+
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ server.bind(os.fspath(self.socket_path))
+ os.chmod(self.socket_path, 0o600)
+ server.listen(16)
+ server.settimeout(_ACCEPT_TIMEOUT_SECONDS)
+ self.warm()
+
+ while not self._stopped:
+ try:
+ conn, _ = server.accept()
+ except TimeoutError:
+ continue
+ except OSError:
+ if self._stopped:
+ break
+ raise
+ with conn:
+ conn.settimeout(_CONNECTION_TIMEOUT_SECONDS)
+ try:
+ self._handle_connection(conn)
+ except OSError:
+ continue
+ finally:
+ server.close()
+ try:
+ self.socket_path.unlink()
+ except FileNotFoundError:
+ pass
+
+ def stop(self, *_: object) -> None:
+ self._stopped = True
+
+ def _handle_connection(self, conn: socket.socket) -> None:
+ try:
+ raw = self._read_line(conn)
+ request = json.loads(raw.decode("utf-8"))
+ response = self._handle_request(request)
+ except Exception as exc:
+ response = {"ok": False, "error": str(exc)}
+
+ payload = json.dumps(_json_safe(response), separators=(",", ":")).encode("utf-8") + b"\n"
+ try:
+ conn.sendall(payload)
+ except OSError:
+ return
+
+ @staticmethod
+ def _read_line(conn: socket.socket) -> bytes:
+ chunks: list[bytes] = []
+ total = 0
+ while True:
+ chunk = conn.recv(65536)
+ if not chunk:
+ break
+ if b"\n" in chunk:
+ before, _, _ = chunk.partition(b"\n")
+ total += len(before)
+ if total > 1_000_000:
+ raise ValueError("request exceeds 1MB")
+ chunks.append(before)
+ break
+ total += len(chunk)
+ if total > 1_000_000:
+ raise ValueError("request exceeds 1MB")
+ chunks.append(chunk)
+ if not chunks:
+ raise ValueError("empty request")
+ return b"".join(chunks)
+
+ def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]:
+ if request.get("method") != "brain_search":
+ raise ValueError(f"unsupported method: {request.get('method')}")
+ arguments = request.get("arguments") or {}
+ if not isinstance(arguments, dict):
+ raise ValueError("arguments must be an object")
+
+ text, structured = asyncio.run(self._search(arguments))
+ metadata: dict[str, Any] = {}
+ if structured is not None:
+ metadata["structuredContent"] = structured
+ return {"ok": True, "text": text, "metadata": metadata}
+
+ async def _search(self, arguments: dict[str, Any]) -> tuple[str, dict[str, Any] | None]:
+ from brainlayer.mcp.search_handler import _brain_search
+
+ source = arguments.get("source")
+ if source is None or source == "":
+ source = "all"
+
+ result = await _brain_search(
+ query=str(arguments.get("query") or ""),
+ project=arguments.get("project"),
+ source=source,
+ tag=arguments.get("tag"),
+ importance_min=arguments.get("importance_min"),
+ num_results=int(arguments.get("num_results") or 5),
+ detail=str(arguments.get("detail") or "compact"),
+ )
+
+ if isinstance(result, tuple):
+ content, structured = result
+ return self._content_text(content), structured if isinstance(structured, dict) else None
+ if hasattr(result, "content"):
+ return self._content_text(result.content), None
+ return self._content_text(result), None
+
+ @staticmethod
+ def _content_text(content: Any) -> str:
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ text = getattr(item, "text", None)
+ if text is None and isinstance(item, dict):
+ text = item.get("text")
+ if text is not None:
+ parts.append(str(text))
+ return "\n".join(parts)
+ text = getattr(content, "text", None)
+ if text is not None:
+ return str(text)
+ if isinstance(content, dict):
+ text = content.get("text")
+ if text is not None:
+ return str(text)
+ return str(content)
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="BrainBar persistent hybrid search helper")
+ parser.add_argument("--socket-path", required=True)
+ parser.add_argument("--db-path")
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ if args.db_path:
+ os.environ["BRAINLAYER_DB"] = args.db_path
+
+ from brainlayer.paths import get_db_path
+
+ helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=get_db_path())
+ signal.signal(signal.SIGINT, helper.stop)
+ helper.serve_forever()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py
index 24611bba..6427ba90 100644
--- a/src/brainlayer/mcp/search_handler.py
+++ b/src/brainlayer/mcp/search_handler.py
@@ -210,7 +210,8 @@ def _exact_chunk_lookup_result(
or is_precompact_checkpoint_content(chunk.get("content"))
):
return _empty_exact_chunk_lookup_result(query)
- if any(value is not None for value in (source, intent, sentiment, source_filter, correction_category)):
+ effective_source = None if source == "all" else source
+ if any(value is not None for value in (effective_source, intent, sentiment, source_filter, correction_category)):
return None
if project is not None:
chunk_project = _normalize_project_name(chunk.get("project")) or chunk.get("project")
@@ -617,6 +618,7 @@ async def _brain_search(
return await _recall(topic=query, project=project, max_results=max_results, include_audit=include_audit)
store = _get_vector_store()
+ effective_source = None if source == "all" else source
exact_chunk_hit = _exact_chunk_lookup_result(
query,
store,
@@ -627,7 +629,7 @@ async def _brain_search(
importance_min=importance_min,
date_from=date_from,
date_to=date_to,
- source=source,
+ source=effective_source,
intent=intent,
sentiment=sentiment,
source_filter=source_filter,
@@ -646,7 +648,7 @@ async def _brain_search(
has_active_filters = any(
[
content_type,
- source,
+ effective_source,
tag,
intent,
importance_min,
diff --git a/src/brainlayer/pipeline/entity_extraction.py b/src/brainlayer/pipeline/entity_extraction.py
index 01b90442..f97ad3c2 100644
--- a/src/brainlayer/pipeline/entity_extraction.py
+++ b/src/brainlayer/pipeline/entity_extraction.py
@@ -11,12 +11,19 @@
import json
import logging
+import os
import re
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
+_FALSEY = {"0", "false", "no"}
+
+
+def _llm_extraction_enabled() -> bool:
+ return os.environ.get("BRAINLAYER_LLM_ENTITY_EXTRACTION", "1").lower() not in _FALSEY
+
@dataclass
class ExtractedEntity:
@@ -312,7 +319,7 @@ def extract_entities_llm(
Returns:
Tuple of (entities, relations).
"""
- if not text.strip():
+ if not text.strip() or (llm_caller is None and not _llm_extraction_enabled()):
return [], []
if llm_caller is None:
diff --git a/tests/conftest.py b/tests/conftest.py
index 436941c4..7cc43c0d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,6 +24,16 @@ def eval_project() -> str:
return f"eval-{uuid.uuid4().hex[:8]}"
+@pytest.fixture(autouse=True)
+def disable_live_gemini_for_unit_tests(monkeypatch, request):
+ """Keep unit tests from making live Gemini calls through local shell env."""
+ if request.node.get_closest_marker("live"):
+ return
+
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
+ monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False)
+
+
@pytest.fixture
def test_user() -> str:
"""Username for path-based tests.
diff --git a/tests/test_arbitration.py b/tests/test_arbitration.py
index 155f495d..913df96d 100644
--- a/tests/test_arbitration.py
+++ b/tests/test_arbitration.py
@@ -152,7 +152,7 @@ def test_drain_daemon_serializes_three_concurrent_producers(tmp_path, monkeypatc
worker.join(timeout=20)
assert worker.exitcode == 0
- deadline = time.monotonic() + 15
+ deadline = time.monotonic() + 45
total_drained = 0
while time.monotonic() < deadline:
total_drained += drain_once(db_path=db_path, queue_dir=queue_dir, batch_size=250, log_path=log_path)
diff --git a/tests/test_brainbar_build_app_guards.py b/tests/test_brainbar_build_app_guards.py
index 55194a7e..b6ba4c2c 100644
--- a/tests/test_brainbar_build_app_guards.py
+++ b/tests/test_brainbar_build_app_guards.py
@@ -108,7 +108,8 @@ def test_build_app_allows_clean_canonical_repo_in_dry_run(tmp_path: Path) -> Non
assert result.returncode == 0
assert str(home / "Applications" / "BrainBar.app") in result.stdout
- assert "LaunchAgent: canonical install" in result.stdout
+ assert "UI LaunchAgent: canonical install" in result.stdout
+ assert "Daemon LaunchAgent: canonical install" in result.stdout
def test_build_app_helpers_ignore_parent_git_hook_env(tmp_path: Path, monkeypatch) -> None:
@@ -174,7 +175,7 @@ def test_build_app_routes_forced_noncanonical_repo_to_dev_bundle(tmp_path: Path)
assert result.returncode == 0
assert str(home / "Applications" / "BrainBar-DEV-feat-ui-guards.app") in result.stdout
- assert "LaunchAgent: skipped for DEV worktree build" in result.stdout
+ assert "LaunchAgents: skipped for DEV worktree build" in result.stdout
def test_build_app_rejects_dirty_canonical_repo_without_force(tmp_path: Path) -> None:
@@ -306,3 +307,22 @@ def test_build_app_rejects_untracked_dirty_repo_even_when_status_hides_untracked
assert result.returncode != 0
assert "dirty" in result.stderr.lower()
assert "UNTRACKED.txt" in result.stderr
+
+
+def test_brainbar_daemon_launchagent_runs_interactive_daemon_binary() -> None:
+ plist = Path(__file__).resolve().parents[1] / "brain-bar" / "bundle" / "com.brainlayer.brainbar-daemon.plist"
+ content = plist.read_text()
+
+ assert "com.brainlayer.brainbar-daemon" in content
+ assert "/Applications/BrainBar.app/Contents/MacOS/BrainBarDaemon" in content
+ assert "ProcessType" in content
+ assert "Interactive" in content
+
+
+def test_brainbar_package_declares_separate_ui_and_daemon_products() -> None:
+ package = Path(__file__).resolve().parents[1] / "brain-bar" / "Package.swift"
+ content = package.read_text()
+
+ assert '.executable(name: "BrainBar", targets: ["BrainBar"])' in content
+ assert '.executable(name: "BrainBarDaemon", targets: ["BrainBarDaemon"])' in content
+ assert 'path: "Sources/BrainBarDaemon"' in content
diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py
new file mode 100644
index 00000000..80960284
--- /dev/null
+++ b/tests/test_brainbar_hybrid_helper.py
@@ -0,0 +1,82 @@
+import pytest
+from mcp.types import TextContent
+
+from brainlayer.brainbar_hybrid_helper import HybridSearchHelper
+
+
+def test_helper_routes_brain_search_to_python_mcp_with_source_all_default(monkeypatch, tmp_path):
+ calls = []
+
+ async def fake_brain_search(**kwargs):
+ calls.append(kwargs)
+ return (
+ [TextContent(type="text", text="hybrid result manual-a0b8a")],
+ {"query": kwargs["query"], "results": [{"chunk_id": "manual-a0b8a"}]},
+ )
+
+ monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search)
+
+ helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db")
+ response = helper._handle_request(
+ {
+ "method": "brain_search",
+ "arguments": {
+ "query": "techgym speakers workshop",
+ "num_results": 3,
+ "project": "brainlayer",
+ "tag": "speakers-workshop",
+ "importance_min": 8,
+ "detail": "compact",
+ },
+ }
+ )
+
+ assert response == {
+ "ok": True,
+ "text": "hybrid result manual-a0b8a",
+ "metadata": {
+ "structuredContent": {
+ "query": "techgym speakers workshop",
+ "results": [{"chunk_id": "manual-a0b8a"}],
+ }
+ },
+ }
+ assert calls == [
+ {
+ "query": "techgym speakers workshop",
+ "project": "brainlayer",
+ "source": "all",
+ "tag": "speakers-workshop",
+ "importance_min": 8,
+ "num_results": 3,
+ "detail": "compact",
+ }
+ ]
+
+
+def test_content_text_extracts_single_dict_text():
+ assert HybridSearchHelper._content_text({"type": "text", "text": "dict text"}) == "dict text"
+
+
+def test_read_line_rejects_oversized_chunk_before_newline():
+ class FakeSocket:
+ def __init__(self):
+ self.chunks = [b"x" * 1_000_001 + b"\n"]
+
+ def recv(self, _size):
+ return self.chunks.pop(0) if self.chunks else b""
+
+ with pytest.raises(ValueError, match="request exceeds 1MB"):
+ HybridSearchHelper._read_line(FakeSocket())
+
+
+def test_handle_connection_ignores_client_disconnect_on_response(monkeypatch, tmp_path):
+ helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db")
+ monkeypatch.setattr(helper, "_read_line", lambda _conn: b'{"method":"brain_search","arguments":{"query":"x"}}')
+ monkeypatch.setattr(helper, "_handle_request", lambda _request: {"ok": True, "text": "result", "metadata": {}})
+
+ class ClosedSocket:
+ def sendall(self, _payload):
+ raise BrokenPipeError("client disconnected")
+
+ helper._handle_connection(ClosedSocket())
diff --git a/tests/test_digest_pipeline_v2.py b/tests/test_digest_pipeline_v2.py
index 730492a9..a74b8e2b 100644
--- a/tests/test_digest_pipeline_v2.py
+++ b/tests/test_digest_pipeline_v2.py
@@ -719,10 +719,11 @@ def test_digest_then_digest_finds_duplicate(self, tmp_store):
class TestPerformance:
"""Tests that the digest pipeline meets performance targets."""
- def test_digest_under_2_seconds(self, tmp_store):
+ def test_digest_under_2_seconds(self, tmp_store, monkeypatch):
"""Digest of 1000-word content completes in <2 seconds."""
from brainlayer.pipeline.digest import digest_content
+ monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0")
content = " ".join(["word"] * 1000)
mock_embed = MagicMock(return_value=[0.05] * 1024)
@@ -737,10 +738,11 @@ def test_digest_under_2_seconds(self, tmp_store):
assert elapsed < 2.0, f"Digest took {elapsed:.2f}s, expected <2s"
- def test_connect_under_2_seconds(self, tmp_store):
+ def test_connect_under_2_seconds(self, tmp_store, monkeypatch):
"""Connect mode for 1000-word content completes in <2 seconds."""
from brainlayer.pipeline.digest import digest_connect
+ monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0")
content = " ".join(["word"] * 1000)
mock_embed = MagicMock(return_value=[0.05] * 1024)
diff --git a/tests/test_search_exact_chunk_id.py b/tests/test_search_exact_chunk_id.py
index 7c855acb..aff40a36 100644
--- a/tests/test_search_exact_chunk_id.py
+++ b/tests/test_search_exact_chunk_id.py
@@ -68,6 +68,35 @@ async def test_brain_search_exact_chunk_id_defaults_missing_project_to_unknown()
assert structured["results"][0]["project"] == "unknown"
+@pytest.mark.asyncio
+async def test_brain_search_exact_chunk_id_treats_source_all_as_unfiltered():
+ """BrainBar forwards source='all'; exact chunk-id lookup must still short-circuit."""
+ chunk_id = "brainbar-sourceall01"
+ mock_store = MagicMock()
+ mock_store.get_chunk.return_value = {
+ "id": chunk_id,
+ "content": "Exact chunk content reachable when source all is unfiltered",
+ "source_file": "docs/repro.md",
+ "project": "brainlayer",
+ "content_type": "note",
+ "importance": 8,
+ "created_at": "2026-05-18T09:15:00Z",
+ "summary": "Source all exact lookup repro",
+ }
+
+ with (
+ patch("brainlayer.mcp.search_handler._get_vector_store", return_value=mock_store),
+ patch(
+ "brainlayer.mcp.search_handler._search",
+ new=AsyncMock(side_effect=AssertionError("source='all' exact chunk-id query should bypass hybrid search")),
+ ),
+ ):
+ _, structured = await _brain_search(query=chunk_id, source="all", detail="compact")
+
+ assert structured["total"] == 1
+ assert structured["results"][0]["chunk_id"] == chunk_id
+
+
@pytest.mark.asyncio
async def test_brain_search_exact_checkpoint_chunk_id_returns_empty_without_fallback():
"""Default exact chunk-id lookup must not leak checkpoint chunks via fallback search."""
diff --git a/tests/test_search_filter_params.py b/tests/test_search_filter_params.py
index 463535d9..8c91079a 100644
--- a/tests/test_search_filter_params.py
+++ b/tests/test_search_filter_params.py
@@ -271,6 +271,50 @@ def test_include_checkpoints_does_not_skip_entity_routing(self):
assert store.kg_hybrid_search.call_args[1]["include_checkpoints"] is True
mock_search.assert_not_awaited()
+ def test_source_all_does_not_skip_entity_routing(self):
+ """source='all' means unfiltered global search, not an active source filter."""
+ store = MagicMock(count=MagicMock(return_value=100))
+ store.kg_hybrid_search.return_value = {
+ "chunks": {
+ "ids": [[]],
+ "documents": [[]],
+ "metadatas": [[]],
+ "distances": [[]],
+ }
+ }
+ fact_items = [{"source": "Etan", "relation": "works_on", "target": "BrainLayer"}]
+ with (
+ patch("brainlayer.mcp.search_handler._get_vector_store", return_value=store),
+ patch("brainlayer.mcp.search_handler._expanded_fts_query", return_value=None),
+ patch(
+ "brainlayer.mcp.search_handler._get_embedding_model",
+ return_value=MagicMock(embed_query=MagicMock(return_value=[0.1] * 1024)),
+ ),
+ patch(
+ "brainlayer.mcp.search_handler._detect_entities",
+ return_value=[{"id": "e1", "name": "Etan", "entity_type": "person"}],
+ ) as mock_detect,
+ patch("brainlayer.mcp.search_handler._kg_facts_sql", return_value=fact_items),
+ patch(
+ "brainlayer.mcp.search_handler._search",
+ new_callable=AsyncMock,
+ return_value=MagicMock(),
+ ) as mock_search,
+ patch("brainlayer.mcp.search_handler._normalize_project_name", return_value=None),
+ ):
+ _content, structured = asyncio.run(
+ _brain_search(
+ query="Etan BrainLayer context",
+ source="all",
+ )
+ )
+
+ mock_detect.assert_called_once_with("Etan BrainLayer context", store)
+ store.kg_hybrid_search.assert_called_once()
+ mock_search.assert_not_awaited()
+ assert structured["entity"] == "Etan"
+ assert structured["facts"] == fact_items
+
# ── Input schema validation ──────────────────────────────────────────────────